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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 13:30:22 +02:00
parent 4eae9b4f5a
commit fec0d94148
24 changed files with 2379 additions and 599 deletions

View file

@ -127,7 +127,7 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AF are also complete: dead host residue is removed, Slice 8 checkpoints AG are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `WorldEnvironmentController` solely owns the clock, DAT 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 physical callback removal. `GameplayInputActionRouter` now owns the sole
semantic action-priority semantic action-priority
graph; typed owners route combat, diagnostics, and remaining commands, and the graph; typed owners route combat, diagnostics, and remaining commands, and the
retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines /
198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign 198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime
baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, settings owner, one immutable startup snapshot, ordered one-shot startup
and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints GL— application, and inert late target binding. `GameWindow` is now 3,663 raw lines /
settings, resource ownership, ordered composition, 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 HL—resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -125,7 +125,7 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AF are also complete: dead host residue is removed, Slice 8 checkpoints AG are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `WorldEnvironmentController` solely owns the clock, DAT 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 physical callback removal. `GameplayInputActionRouter` now owns the sole
semantic action-priority semantic action-priority
graph; typed owners route combat, diagnostics, and remaining commands, and the graph; typed owners route combat, diagnostics, and remaining commands, and the
retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines / retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines /
198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign 198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime
baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate, settings owner, one immutable startup snapshot, ordered one-shot startup
and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints GL— application, and inert late target binding. `GameWindow` is now 3,663 raw lines /
settings, resource ownership, ordered composition, 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 HL—resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -27,13 +27,14 @@ What does NOT go here:
## Current queue — 2026-07-22 ## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 are - **Active structural work:** `GameWindow` decomposition. Slices 17 are
complete; Slice 8 checkpoints AF now own native callbacks, live-session complete; Slice 8 checkpoints AG now own native callbacks, live-session
composition, world-environment state, reversible device input/raw pointer, composition, world-environment state, reversible device input/raw pointer,
physical framebuffer publication, gameplay action priority, typed commands, 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 `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining handoffs and the class is 3,663 lines / 162 fields / 37 methods. Remaining
Slice 8 settings/resource/composition/shutdown Slice 8 resource/composition/shutdown
cleanup is active in cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -829,11 +829,15 @@ Current movement/collision ownership:
routers; Slice 4 moved hydration, teardown, inbound authority, network routers; Slice 4 moved hydration, teardown, inbound authority, network
updates, and reusable motion-runtime policy over canonical updates, and reusable motion-runtime policy over canonical
`LiveEntityRuntime`. Slices 57 complete landblock presentation and the `LiveEntityRuntime`. Slices 57 complete landblock presentation and the
update/render frame graphs. Slice 8 checkpoints AF now own the native update/render frame graphs. Slice 8 checkpoints AG now own the native
callback shell, live-session composition, world environment, reversible callback shell, live-session composition, world environment, reversible
input/pointer lifetime, physical framebuffer publication, the sole gameplay input/pointer lifetime, physical framebuffer publication, the sole gameplay
action-priority graph, typed commands, and the retained-root item-drop edge. action-priority graph, typed commands, the retained-root item-drop edge, and
Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in 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 `docs/architecture/code-structure.md`. This decision does not reclassify the
remaining L.2 conformance work or Track LH. remaining L.2 conformance work or Track LH.

View file

@ -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 D 4,330 lines / 192 fields / 67 methods
after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 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 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: `GameWindow` is the single object that:
@ -195,8 +196,9 @@ documentation, not an exhaustive allowlist):
shared root and sibling controls. shared root and sibling controls.
- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for - `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for
the optional developer UI. the optional developer UI.
- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM` - `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview ->
for the live settings draft preview applied before world drawing. IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional
SettingsVM` for the live settings draft preview applied before world drawing.
- `LocalPlayerPortalViewport -> LocalPlayerTeleportController -> - `LocalPlayerPortalViewport -> LocalPlayerTeleportController ->
GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the
canonical portal/input lifetime and the host's input-action subscription. 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, Silk callbacks become narrow calls into the input, update, render, resize,
focus, and shutdown owners. 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` camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
alone publishes physical framebuffer changes. All Silk, dispatcher, retained alone publishes physical framebuffer changes. All Silk, dispatcher, retained
UI, and optional devtools device edges are reversible, transactional, and 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 diagnostic, and general command owners replace the remaining window command
bodies. The retained-root item-drop edge has one reversible owner, while its bodies. The retained-root item-drop edge has one reversible owner, while its
deferred toolbar targets become inert before session retirement. `GameWindow` deferred toolbar targets become inert before session retirement. `GameWindow`
is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints GL remain now has no settings-store construction, duplicate settings loads, persisted
active, with two-phase runtime settings next. 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 HL 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 ### 4.4 Exit criteria

View file

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

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # 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 17 and Slice 8 checkpoints AF 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 17 and Slice 8 checkpoints AG 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. **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 checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
process-residency gate's diagnostic blind spot; no tolerance was loosened. process-residency gate's diagnostic blind spot; no tolerance was loosened.
Final Slice 8 composition/shutdown cleanup is active. Checkpoints AF now own Final Slice 8 composition/shutdown cleanup is active. Checkpoints AG now own
the frozen shell boundary, all nine native callback edges, live-session the frozen shell boundary, all nine native callback edges, live-session
reset/selection/entry/route composition, and world clock/sky/day/weather/ reset/selection/entry/route composition, and world clock/sky/day/weather/
AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, 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 subscriber, typed combat/diagnostic/general commands, and a reversible
retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields / 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. 54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips.
Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable
GL remain. 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 HL 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 This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4 root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -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 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 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
nine-destination soak pass with graceful exits, and six stable PNG checkpoints nine-destination soak pass with graceful exits, and six stable PNG checkpoints
preserve Slice 6 presentation. Final Slice 8 checkpoints AF are complete: preserve Slice 6 presentation. Final Slice 8 checkpoints AG are complete:
native callbacks and live-session composition now have explicit retryable native callbacks and live-session composition now have explicit retryable
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, 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 camera pointer, focus, and physical framebuffer size. Graceful live-session
retirement now precedes physical callback removal. Checkpoint F adds the sole retirement now precedes physical callback removal. Checkpoint F adds the sole
gameplay action-priority owner, typed combat/diagnostic/general commands, and a gameplay action-priority owner, typed combat/diagnostic/general commands, and a
reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198 reversible retained-root item-drop binding. Checkpoint G adds the sole
fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints GL remain pre-window/runtime settings owner, one startup snapshot, ordered one-shot
active. Issue #232 tracks process-residency variance in the soak without startup application, and inert late target binding. `GameWindow` is 3,663
loosening its leak threshold. lines / 162 fields / 37 methods. Checkpoints HL 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: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

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

View file

@ -25,7 +25,7 @@ audit.
resize behind symmetric named subscriptions. resize behind symmetric named subscriptions.
- [x] F — extract the sole gameplay input-action router plus focused combat and - [x] F — extract the sole gameplay input-action router plus focused combat and
diagnostic command owners. 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 - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
explicit single ownership and transfer seams. explicit single ownership and transfer seams.
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with - [ ] 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 - Preserve restart-required quality behavior and test transitions/previews
without requiring GL. 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 ### H — explicit single resource ownership and frame slots
- Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots; - Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots;

View file

@ -3,9 +3,9 @@
## Current state ## Current state
The behavior-preserving App ownership campaign is complete through Slice 7 and The behavior-preserving App ownership campaign is complete through Slice 7 and
Slice 8 checkpoints AF. `GameWindow.cs` moved from the 2026-07-21 baseline of Slice 8 checkpoints AG. `GameWindow.cs` moved from the 2026-07-21 baseline of
15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54 15,723 lines / 278 fields / 205 methods to 3,663 lines / 162 fields / 37
methods: 11,666 lines (74.2%) were removed without changing accepted gameplay methods: 12,060 lines (76.7%) were removed without changing accepted gameplay
or rendering behavior. or rendering behavior.
| Slice | Ownership moved out | Closeout size | | 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 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
| 8 AE | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 AE | 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 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 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 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 short-circuits directly to peace, and an incompatible Held object prints the
exact retail notice without a wire request or local mode mutation. 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`, Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
@ -111,6 +128,12 @@ review rule live in `docs/architecture/code-structure.md`.
## Accepted verification ## 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 - 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 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 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 ## Next work
Slice 8 checkpoints GL group the remaining settings, resource ownership, Slice 8 checkpoints HL group the remaining resource ownership, ordered
startup, and shutdown wiring and reduce Silk callbacks to startup composition, and shutdown wiring and reduce Silk callbacks to
narrow calls into input, update, render, resize, focus, and shutdown owners. narrow calls into input, update, render, resize, focus, and shutdown owners.
It removes leftover feature state only where Slices 17 already established a It removes leftover feature state only where Slices 17 already established a
canonical owner; it does not redesign gameplay, rendering, or resource lifetime. canonical owner; it does not redesign gameplay, rendering, or resource lifetime.

View file

@ -1,5 +1,6 @@
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Settings;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using DatReaderWriter; using DatReaderWriter;
@ -97,6 +98,7 @@ public sealed class GameWindow : IDisposable
new(Console.WriteLine); new(Console.WriteLine);
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing; private readonly DisplayFramePacingController _displayFramePacing;
private readonly RuntimeSettingsController _runtimeSettings;
// Phase A.1: streaming fields replacing the one-shot _entities list. // Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.LandblockStreamer? _streamer;
@ -124,14 +126,6 @@ public sealed class GameWindow : IDisposable
get => _renderRange.FarRadius; get => _renderRange.FarRadius;
set => _renderRange.FarRadius = value; 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. // Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
@ -563,6 +557,10 @@ public sealed class GameWindow : IDisposable
_displayFramePacing = new DisplayFramePacingController( _displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering, options.UncappedRendering,
_frameProfiler); _frameProfiler);
_runtimeSettings = new RuntimeSettingsController(
new JsonRuntimeSettingsStorage(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()),
log: Console.WriteLine);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
@ -594,17 +592,13 @@ public sealed class GameWindow : IDisposable
public void Run() public void Run()
{ {
// A.5 T22.5: resolve quality preset BEFORE creating the window so // The runtime-settings owner was constructed before Window.Create and
// Samples (MSAA) is baked into WindowOptions correctly. GL context // exposes the one immutable startup snapshot. MSAA is a context
// sample count cannot change at runtime; all other quality fields are // attribute, so it must come from this same snapshot rather than a
// applied again in OnLoad after the full settings load. // second settings load during OnLoad.
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( RuntimeSettingsSnapshot startup = _runtimeSettings.Startup;
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);
FramePacingPolicy startupPacing = FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startupDisplay.VSync); _displayFramePacing.InitializeStartup(startup.Display.VSync);
var options = WindowOptions.Default with var options = WindowOptions.Default with
{ {
Size = new Vector2D<int>(1280, 720), Size = new Vector2D<int>(1280, 720),
@ -619,7 +613,7 @@ public sealed class GameWindow : IDisposable
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would // Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning. // 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 // #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement // 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 // Settings are runtime state, not devtools state. Apply the immutable
// BEFORE the DevToolsEnabled block. The settings.json values // pre-window snapshot once after camera/audio exist and before any
// (resolution, vsync, FOV, master volume, etc) are runtime // optional frontend or world/render factory consumes it.
// settings, not devtools settings — a user running without _runtimeSettings.ApplyStartup(
// ACDREAM_DEVTOOLS=1 still expects their saved values to take new RuntimeSettingsStartupTargets(
// effect. The Settings PANEL (editing UI) is gated on devtools; new SilkRuntimeDisplayWindowTarget(_window!),
// the persisted state is not. Caches values into fields so the _displayFramePacing,
// SettingsVM construction in the devtools block reads them _cameraController!,
// without re-loading. _audioEngine));
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}");
}
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var // Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit. // isn't set: no context creation, no per-frame branches hit.
@ -872,6 +853,7 @@ public sealed class GameWindow : IDisposable
if (DevToolsEnabled) if (DevToolsEnabled)
{ {
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
AcDream.UI.Abstractions.Panels.Settings.SettingsVM? settingsVm = null;
try try
{ {
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
@ -984,19 +966,12 @@ public sealed class GameWindow : IDisposable
// dispatcher because the dispatcher is built earlier in // dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field). // the same OnLoad path (see _inputDispatcher field).
AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null; 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 settingsVm = _runtimeSettings.CreateViewModel(
// happened earlier in OnLoad via _keyBindings,
// LoadAndApplyPersistedSettings (settings are runtime _inputDispatcher,
// state, not devtools state — they take effect even bindings =>
// 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 =>
{ {
_inputDispatcher.SetBindings(bindings); _inputDispatcher.SetBindings(bindings);
try try
@ -1011,120 +986,9 @@ public sealed class GameWindow : IDisposable
{ {
Console.WriteLine($"keybinds: save failed: {ex.Message}"); 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 = settingsPanel =
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm); new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(settingsVm);
panelHost.Register(settingsPanel); panelHost.Register(settingsPanel);
} }
@ -1185,7 +1049,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm = null; _vitalsVm = null;
_combatFeedback.ViewModel = null; _combatFeedback.ViewModel = null;
_debugVm = 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() // A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low // hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect. // (4x) and Medium (8x) actually take effect.
terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel); terrainAtlas.SetAnisotropic(_runtimeSettings.ResolvedQuality.AnisotropicLevel);
_terrain = new TerrainModernRenderer( _terrain = new TerrainModernRenderer(
_gl, _gl,
@ -1302,7 +1166,7 @@ public sealed class GameWindow : IDisposable
_combatTargetController = new AcDream.App.Combat.CombatTargetController( _combatTargetController = new AcDream.App.Combat.CombatTargetController(
Combat, Combat,
_selection, _selection,
autoTarget: () => _persistedGameplay.AutoTarget, autoTarget: () => _runtimeSettings.Gameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false)); selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController( _externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers, _externalContainers,
@ -1372,7 +1236,7 @@ public sealed class GameWindow : IDisposable
_debugFont, _debugFont,
_hostQuiescence); _hostQuiescence);
_retainedInputCapture.Root = _uiHost.Root; _retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _persistedGameplay.LockUI; _uiHost.Root.UiLocked = _runtimeSettings.Gameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController, _itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode // Retail UpdateCursorState (0x00564630) keys target-mode
@ -1384,7 +1248,7 @@ public sealed class GameWindow : IDisposable
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider( _characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer, Objects, LocalPlayer,
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey, activeToonName: () => _runtimeSettings.ActiveToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter, fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionHost?.IsInWorld == true, canSendRaise: () => _liveSessionHost?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost), sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
@ -1463,17 +1327,19 @@ public sealed class GameWindow : IDisposable
playerYawRadians: () => _playerController?.Yaw ?? 0f, playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u, playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId, selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, coordinatesOnRadar: () => _runtimeSettings.Gameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI, uiLocked: () => _runtimeSettings.Gameplay.LockUI,
playerEntities: () => _entitiesByServerGuid, playerEntities: () => _entitiesByServerGuid,
spatialQuery: () => _worldState); spatialQuery: () => _worldState);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm; _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 ? null
: new AcDream.App.UI.RetailUiPersistenceBindings( : new AcDream.App.UI.RetailUiPersistenceBindings(
_settingsStore, layoutStore,
CharacterKey: () => _activeToonKey, CharacterKey: () => _runtimeSettings.ActiveToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y)); ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
@ -1508,12 +1374,12 @@ public sealed class GameWindow : IDisposable
Radar: new AcDream.App.UI.RadarRuntimeBindings( Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot, radarSnapshotProvider.BuildSnapshot,
_selection, _selection,
SetRetailUiLocked), _runtimeSettings.SetUiLocked),
Combat: new AcDream.App.UI.CombatRuntimeBindings( Combat: new AcDream.App.UI.CombatRuntimeBindings(
Combat, Combat,
_combatAttackController, _combatAttackController,
() => _persistedGameplay, () => _runtimeSettings.Gameplay,
SetRetailCombatGameplay), _runtimeSettings.SetCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings( Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook, SpellBook,
_magicRuntime.Casting, _magicRuntime.Casting,
@ -1549,12 +1415,11 @@ public sealed class GameWindow : IDisposable
// adaptive distance degradation (tracked by TS-15), so its // adaptive distance degradation (tracked by TS-15), so its
// effective multiplier is exactly 1.0. // effective multiplier is exactly 1.0.
() => 1.0, () => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps () => _runtimeSettings.DisplayPreview.ShowFps),
?? _persistedDisplay.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings( VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection, _selection,
() => _playerServerGuid, () => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator, () => _runtimeSettings.Gameplay.VividTargetingIndicator,
ResolveVividTargetInfo, ResolveVividTargetInfo,
GetSelectionCamera), GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings( Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
@ -2019,7 +1884,8 @@ public sealed class GameWindow : IDisposable
_retainedUiGameplayBinding.Attach(); _retainedUiGameplayBinding.Attach();
} }
// A.5 T22.5: apply A2C gate from quality preset. // 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 // 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 // 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, _options.UiProbeDump,
resourceDiagnostics); resourceDiagnostics);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality. // Apply radii from the same immutable quality snapshot used for the
// _resolvedQuality was set by the quality block immediately after // window's MSAA, terrain anisotropy, and dispatcher A2C setup.
// LoadAndApplyPersistedSettings() above, absorbing all env-var overrides.
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat. // Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
_nearRadius = _resolvedQuality.NearRadius; _nearRadius = _runtimeSettings.ResolvedQuality.NearRadius;
_farRadius = _resolvedQuality.FarRadius; _farRadius = _runtimeSettings.ResolvedQuality.FarRadius;
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and // Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius. Parsed once into // ensures farRadius >= streamRadius. Parsed once into
@ -2233,7 +2098,17 @@ public sealed class GameWindow : IDisposable
_streamingController, _streamingController,
_liveWorldOrigin); _liveWorldOrigin);
// A.5 T22.5: apply max-completions from resolved quality. // 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( _worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident, isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady, isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
@ -2397,7 +2272,7 @@ public sealed class GameWindow : IDisposable
_liveEntities!, _liveEntities!,
Objects, Objects,
_localPlayerIdentity), _localPlayerIdentity),
_gameplaySettings, _runtimeSettings,
_playerControllerSlot, _playerControllerSlot,
_localPlayerOutbound, _localPlayerOutbound,
_liveSessionController, _liveSessionController,
@ -2452,8 +2327,7 @@ public sealed class GameWindow : IDisposable
_scriptRunner!, _scriptRunner!,
_updateFrameClock, _updateFrameClock,
new AcDream.App.Update.SettingsParticleRangeSource( new AcDream.App.Update.SettingsParticleRangeSource(
_settingsVm, _runtimeSettings));
_persistedDisplay.ParticleRange));
var liveSpatialReconciler = var liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler( new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!, _entityEffects!,
@ -2659,7 +2533,7 @@ public sealed class GameWindow : IDisposable
_worldReveal, _worldReveal,
_envCellFrustum), _envCellFrustum),
new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview( new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview(
_settingsVm, _runtimeSettings,
_audioEngine, _audioEngine,
_cameraController!, _cameraController!,
_displayFramePacing), _displayFramePacing),
@ -2803,7 +2677,7 @@ public sealed class GameWindow : IDisposable
localPlayerFrame, localPlayerFrame,
liveSpatialReconciler, liveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource( new AcDream.App.Combat.CombatCameraTargetSource(
_gameplaySettings, _runtimeSettings,
Combat, Combat,
_selection, _selection,
_worldSelectionQuery!)); _worldSelectionQuery!));
@ -2929,10 +2803,10 @@ public sealed class GameWindow : IDisposable
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id, AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
ClearCombat: Combat.Clear), ClearCombat: Combat.Clear),
EnteredWorld: new( EnteredWorld: new(
SetActiveCharacter: name => _activeToonKey = name, SetActiveCharacter: _runtimeSettings.SetActiveCharacter,
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(), RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
SyncToolbar: SyncToolbarWindowButtons, SyncToolbar: SyncToolbarWindowButtons,
LoadCharacterSettings: LoadLiveSessionCharacterSettings, LoadCharacterSettings: _runtimeSettings.LoadCharacterContext,
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()), ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
Connecting: (host, port, user) => Connecting: (host, port, user) =>
Chat.OnSystemMessage( Chat.OnSystemMessage(
@ -2943,17 +2817,6 @@ public sealed class GameWindow : IDisposable
"connected — character list received", "connected — character list received",
chatType: 1))); 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 private AcDream.App.Net.LiveSessionResetBindings
CreateLiveSessionResetBindings() => new() CreateLiveSessionResetBindings() => new()
{ {
@ -2962,8 +2825,7 @@ public sealed class GameWindow : IDisposable
TeleportTransit = _localPlayerTeleportSink.ResetSession, TeleportTransit = _localPlayerTeleportSink.ResetSession,
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(), SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(), ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = () => SettingsCharacterContext = _runtimeSettings.RestoreDefaultCharacterContext,
_settingsVm?.LoadCharacterContext(_persistedCharacter),
EquippedChildren = () => _equippedChildRenderer?.Clear(), EquippedChildren = () => _equippedChildRenderer?.Clear(),
ExternalContainer = () => _externalContainers.Reset(), ExternalContainer = () => _externalContainers.Reset(),
InteractionAndSelection = ResetSessionInteraction, InteractionAndSelection = ResetSessionInteraction,
@ -3011,7 +2873,7 @@ public sealed class GameWindow : IDisposable
_vitalsVm?.SetLocalPlayerGuid(0u); _vitalsVm?.SetLocalPlayerGuid(0u);
Chat.ResetSessionIdentity(); Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u; AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_activeToonKey = "default"; _runtimeSettings.ResetActiveCharacterKey();
_characterOptions1 = _characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default; AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_localPlayerSkills.ResetSession(); _localPlayerSkills.ResetSession();
@ -3132,8 +2994,9 @@ public sealed class GameWindow : IDisposable
TeleportToMansion: session.SendTeleportToMansion, TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge, QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth, QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate, ToggleFrameRate: _runtimeSettings.ToggleFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), ToggleUiLock: () =>
_runtimeSettings.SetUiLocked(!_runtimeSettings.Gameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u), ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null), ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () => PlayerPublicWeenieBitfield: () =>
@ -3155,8 +3018,8 @@ public sealed class GameWindow : IDisposable
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away), SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage, SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, AcceptLootPermits: () => _runtimeSettings.Gameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits, SetAcceptLootPermits: _runtimeSettings.SetAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent, DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent, ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent, RemoveConsent: session.SendRemoveConsent,
@ -3327,238 +3190,20 @@ public sealed class GameWindow : IDisposable
private bool GetDebugPlayerOnGround() => private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne; _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() private void SyncToolbarWindowButtons()
{ {
_retailUiRuntime?.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) private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{ {
session.SendSetAfkMode(away); session.SendSetAfkMode(away);
} }
private void SetRetailAcceptLootPermits(bool enabled)
{
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
_settingsVm?.SetGameplay(
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
_settingsStore?.SaveGameplay(_persistedGameplay);
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
/// flip the live display flag and persist it through the same settings
/// path used by the Display panel.
/// </summary>
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;
/// <summary>
/// L.0 follow-up: load every section from settings.json + apply the
/// runtime-affecting ones (Display window state + Audio engine
/// volumes) at startup. Runs unconditionally — settings are runtime
/// state, not devtools state. Without this, a user running with
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
/// defaults instead of their saved Display/Audio preferences.
/// </summary>
private void LoadAndApplyPersistedSettings()
{
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
_persistedDisplay = _settingsStore.LoadDisplay();
_persistedAudio = _settingsStore.LoadAudio();
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// 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;
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: transactionally reconciles the existing
/// <see cref="_streamingController"/> against its published world;
/// the worker and controller retain their identity.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
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}");
}
}
/// <summary> /// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport /// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging /// + 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 /// Resolution). Without this, the viewport stays pinned at the
/// startup size, producing a small render inside a big window. /// startup size, producing a small render inside a big window.
/// Also force-resets ImGui panel layout so panels that were /// 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<int> newSize) private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
=> _framebufferResize.Resize(newSize); => _framebufferResize.Resize(newSize);
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec)) return false;
var parts = spec.Split('x', 2);
if (parts.Length != 2) return false;
return int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
/// <summary> /// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the /// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
@ -3763,6 +3367,7 @@ public sealed class GameWindow : IDisposable
]), ]),
new ResourceShutdownStage("input callback detach", new ResourceShutdownStage("input callback detach",
[ [
new("settings view model", () => _runtimeSettings.UnbindViewModel()),
new("retained gameplay", () => new("retained gameplay", () =>
{ {
AcDream.App.Input.RetainedUiGameplayBinding? binding = AcDream.App.Input.RetainedUiGameplayBinding? binding =
@ -3836,6 +3441,7 @@ public sealed class GameWindow : IDisposable
// the later render-frontend stage still disposes the GL owners. // the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers", new ResourceShutdownStage("frame borrowers",
[ [
new("runtime settings targets", _runtimeSettings.UnbindRuntimeTargets),
new("world frame composition", () => new("world frame composition", () =>
{ {
_renderFrameOrchestrator = null; _renderFrameOrchestrator = null;

View file

@ -444,7 +444,8 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary> /// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at /// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.ReapplyQualityPreset"/> when /// runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with /// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual /// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters /// change. The texture must not be resident-bindless when its parameters

View file

@ -378,7 +378,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// Default true matches T20 behavior. Set false for Low/Medium presets that /// 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 /// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off
/// avoids the unnecessary GL state thrash and is cleaner diagnostics). /// avoids the unnecessary GL state thrash and is cleaner diagnostics).
/// Can be toggled mid-session via <see cref="GameWindow.ReapplyQualityPreset"/>. /// Can be toggled mid-session via
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/>.
/// </summary> /// </summary>
public bool AlphaToCoverage { get; set; } = true; public bool AlphaToCoverage { get; set; } = true;

View file

@ -4,13 +4,13 @@ using AcDream.App.Input;
using AcDream.App.Rendering.Selection; using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Settings;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Lighting; using AcDream.Core.Lighting;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -348,18 +348,18 @@ internal sealed class RuntimeWorldFrameVisibilityPreparation
internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview
{ {
private readonly SettingsVM? _settings; private readonly IRuntimeSettingsPreviewSource _settings;
private readonly OpenAlAudioEngine? _audio; private readonly OpenAlAudioEngine? _audio;
private readonly CameraController _cameras; private readonly CameraController _cameras;
private readonly DisplayFramePacingController _pacing; private readonly DisplayFramePacingController _pacing;
public RuntimeWorldFrameSettingsPreview( public RuntimeWorldFrameSettingsPreview(
SettingsVM? settings, IRuntimeSettingsPreviewSource settings,
OpenAlAudioEngine? audio, OpenAlAudioEngine? audio,
CameraController cameras, CameraController cameras,
DisplayFramePacingController pacing) DisplayFramePacingController pacing)
{ {
_settings = settings; _settings = settings ?? throw new ArgumentNullException(nameof(settings));
_audio = audio; _audio = audio;
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing)); _pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
@ -367,23 +367,15 @@ internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPrev
public void Apply(in WorldCameraFrame camera) public void Apply(in WorldCameraFrame camera)
{ {
if (_audio is { IsAvailable: true } && _settings is not null) if (_settings.HasDraftPreview)
{ {
var audio = _settings.AudioDraft; RuntimeSettingsStartupTargets.ApplyAudio(
_audio.MasterVolume = audio.Master; _audio,
_audio.MusicVolume = audio.Music; _settings.AudioPreview);
_audio.SfxVolume = audio.Sfx; var display = _settings.DisplayPreview;
_audio.AmbientVolume = audio.Ambient; RuntimeSettingsStartupTargets.ApplyFieldOfView(
} _cameras,
display.FieldOfView);
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;
_pacing.ApplyPreference(display.VSync); _pacing.ApplyPreference(display.VSync);
} }

View file

@ -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; }
}
/// <summary>
/// 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.
/// </summary>
internal sealed class RuntimeSettingsController :
IRuntimeSettingsPreviewSource,
ICombatGameplaySettingsSource
{
private const string DefaultToonKey = "default";
private readonly IRuntimeSettingsStorage _storage;
private readonly Func<QualityPreset, QualitySettings> _resolveQuality;
private readonly Action<string> _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<QualityPreset, QualitySettings>? resolveQuality = null,
Action<string>? 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}");
}
/// <summary>
/// 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.
/// </summary>
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<KeyBindings> 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,
});
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>
/// 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).
/// </summary>
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));
}

View file

@ -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<int>(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)
{
}
}
/// <summary>
/// Complete late-bound target for changes made after startup. Construction and
/// binding are inert; only an explicit controller command mutates borrowers.
/// </summary>
internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
{
private readonly IRuntimeDisplayWindowTarget _displayWindow;
private readonly IRuntimeQualityApplicationTarget _quality;
private readonly IRuntimeUiLockTarget _uiLock;
private readonly Action<string> _log;
public RuntimeSettingsTargets(
IRuntimeDisplayWindowTarget displayWindow,
WbDrawDispatcher dispatcher,
TerrainAtlas terrainAtlas,
StreamingController streaming,
WorldRenderRangeState renderRange,
UiRoot? uiRoot,
Action<string>? 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<string>? 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);
}

View file

@ -3,6 +3,7 @@ using AcDream.App.Input;
using AcDream.App.Interaction; using AcDream.App.Interaction;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Settings;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Rendering; using AcDream.Core.Rendering;
@ -39,17 +40,15 @@ internal interface IParticleRangeSource
internal sealed class SettingsParticleRangeSource : IParticleRangeSource internal sealed class SettingsParticleRangeSource : IParticleRangeSource
{ {
private readonly SettingsVM? _settings; private readonly IRuntimeSettingsPreviewSource _settings;
private readonly ParticleRange _fallback;
public SettingsParticleRangeSource(SettingsVM? settings, ParticleRange fallback) public SettingsParticleRangeSource(IRuntimeSettingsPreviewSource settings)
{ {
_settings = settings; _settings = settings ?? throw new ArgumentNullException(nameof(settings));
_fallback = fallback;
} }
public float RangeMultiplier => public float RangeMultiplier =>
(_settings?.DisplayDraft.ParticleRange ?? _fallback) == ParticleRange.Extended _settings.DisplayPreview.ParticleRange == ParticleRange.Extended
? ParticleVisibilityController.ExtendedRangeMultiplier ? ParticleVisibilityController.ExtendedRangeMultiplier
: 1f; : 1f;
} }

View file

@ -189,6 +189,21 @@ public sealed class SettingsVM
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
} }
/// <summary>
/// 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.
/// </summary>
public void ApplyExternalGameplayChange(
Func<GameplaySettings, GameplaySettings> update)
{
ArgumentNullException.ThrowIfNull(update);
_gameplayPersisted = update(_gameplayPersisted)
?? throw new InvalidOperationException("Gameplay update returned null.");
_gameplayDraft = update(_gameplayDraft)
?? throw new InvalidOperationException("Gameplay update returned null.");
}
/// <summary> /// <summary>
/// Replace the entire Chat draft with <paramref name="value"/>. /// Replace the entire Chat draft with <paramref name="value"/>.
/// Local-only this phase — values persist on Save but the Hear*Chat /// Local-only this phase — values persist on Save but the Hear*Chat

View file

@ -15,11 +15,10 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder( AssertAppearsInOrder(
body, body,
"startupStore.LoadDisplay()", "RuntimeSettingsSnapshot startup = _runtimeSettings.Startup",
"QualitySettings.WithEnvOverrides(startupBase)", "_displayFramePacing.InitializeStartup(startup.Display.VSync)",
"_displayFramePacing.InitializeStartup(startupDisplay.VSync)",
"VSync = startupPacing.UseVSync", "VSync = startupPacing.UseVSync",
"Samples = startupQuality.MsaaSamples", "Samples = startup.Quality.MsaaSamples",
"Window.Create(options)", "Window.Create(options)",
"_displayFramePacing.BindSurface(", "_displayFramePacing.BindSurface(",
"_windowCallbacks = SilkWindowCallbackBinding.Create(", "_windowCallbacks = SilkWindowCallbackBinding.Create(",
@ -66,7 +65,8 @@ public sealed class GameWindowSlice8BoundaryTests
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
"_cameraPointerInput.AttachRaw();", "_cameraPointerInput.AttachRaw();",
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
"LoadAndApplyPersistedSettings();", "_runtimeSettings.ApplyStartup(",
"new RuntimeSettingsStartupTargets(",
"_uiHost = new AcDream.App.UI.UiHost(", "_uiHost = new AcDream.App.UI.UiHost(",
"_uiHost.WireMouse(m)", "_uiHost.WireMouse(m)",
"_uiHost.WireKeyboard(kb)", "_uiHost.WireKeyboard(kb)",
@ -192,7 +192,7 @@ public sealed class GameWindowSlice8BoundaryTests
"private AcDream.App.Net.LiveSessionHost"); "private AcDream.App.Net.LiveSessionHost");
string body = MethodBody( string body = MethodBody(
"private void OnFramebufferResize(", "private void OnFramebufferResize(",
"private void ApplyDisplayWindowState("); "private uint? PickWorldGuidAtCursor(");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal); Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal); Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
@ -209,6 +209,68 @@ public sealed class GameWindowSlice8BoundaryTests
StringComparison.Ordinal); 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] [Fact]
public void UpdateRenderFocusAndCloseRemainNarrowHostEdges() public void UpdateRenderFocusAndCloseRemainNarrowHostEdges()
{ {

View file

@ -152,7 +152,7 @@ public sealed class PrivatePresentationRendererTests
typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM), typeof(AcDream.UI.Abstractions.Panels.Debug.DebugVM),
FieldTypes(typeof(DevToolsPanelSet))); FieldTypes(typeof(DevToolsPanelSet)));
Assert.Contains( Assert.Contains(
typeof(AcDream.UI.Abstractions.Panels.Settings.SettingsVM), typeof(AcDream.App.Settings.IRuntimeSettingsPreviewSource),
FieldTypes(typeof(RuntimeWorldFrameSettingsPreview))); FieldTypes(typeof(RuntimeWorldFrameSettingsPreview)));
Assert.Contains( Assert.Contains(
typeof(AcDream.App.Streaming.LocalPlayerTeleportController), typeof(AcDream.App.Streaming.LocalPlayerTeleportController),

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,7 @@
using AcDream.App.Settings;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Update; using AcDream.App.Update;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
using Silk.NET.Input;
namespace AcDream.App.Tests.World; namespace AcDream.App.Tests.World;
@ -13,73 +12,45 @@ public sealed class LiveObjectFrameControllerTests
[InlineData( [InlineData(
ParticleRange.Extended, ParticleRange.Extended,
ParticleVisibilityController.ExtendedRangeMultiplier)] ParticleVisibilityController.ExtendedRangeMultiplier)]
public void ParticleRange_NullSettingsUsesConfiguredFallback( public void ParticleRange_UsesCurrentSettingsPreview(
ParticleRange fallback, ParticleRange range,
float expected) 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); Assert.Equal(expected, source.RangeMultiplier);
} }
[Fact] [Fact]
public void ParticleRange_LiveSettingsDraftOverridesFallbackAndUpdatesImmediately() public void ParticleRange_UpdatesImmediatelyWhenPreviewChanges()
{ {
SettingsVM settings = CreateSettings(); var preview = new FakeSettingsPreview(DisplaySettings.Default);
var source = new SettingsParticleRangeSource(settings, ParticleRange.Retail); 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); Assert.Equal(1f, source.RangeMultiplier);
settings.SetDisplay(settings.DisplayDraft with { ParticleRange = ParticleRange.Extended }); preview.DisplayPreview = preview.DisplayPreview with
{
ParticleRange = ParticleRange.Extended,
};
Assert.Equal( Assert.Equal(
ParticleVisibilityController.ExtendedRangeMultiplier, ParticleVisibilityController.ExtendedRangeMultiplier,
source.RangeMultiplier); source.RangeMultiplier);
} }
private static SettingsVM CreateSettings() private sealed class FakeSettingsPreview(DisplaySettings display)
: IRuntimeSettingsPreviewSource
{ {
var dispatcher = InputDispatcher.CreateDetached( public bool HasDraftPreview => true;
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 _ => { });
}
private sealed class NullKeyboardSource : IKeyboardSource public DisplaySettings DisplayPreview { get; set; } = display;
{
#pragma warning disable CS0067
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
#pragma warning restore CS0067
public bool IsHeld(Key key) => false;
public ModifierMask CurrentModifiers => ModifierMask.None;
}
private sealed class NullMouseSource : IMouseSource public AudioSettings AudioPreview => AudioSettings.Default;
{
#pragma warning disable CS0067
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
#pragma warning restore CS0067
public bool IsHeld(MouseButton button) => false;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
} }
} }

View file

@ -434,6 +434,44 @@ public sealed class SettingsVMTests
Assert.True(vm.HasUnsavedChanges); 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] [Fact]
public void Save_invokes_gameplay_callback_with_draft() public void Save_invokes_gameplay_callback_with_draft()
{ {