refactor(input): own pointer and callback lifetime

Move camera pointer, framebuffer resize, and retained/devtools input edges behind focused reversible owners. Preserve input priority while making shutdown deactivate callbacks before live-session retirement and retry physical detach without stranding transport teardown.
This commit is contained in:
Erik 2026-07-22 11:59:33 +02:00
parent d09e246d3a
commit 8b8afeefa3
42 changed files with 4029 additions and 461 deletions

View file

@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; 7,341 tests pass / 5 skip. Release builds; 7,477 tests pass / 5 skip.
**Active prerequisite before new M4 subsystem work:** continue the **Active prerequisite before new M4 subsystem work:** continue the
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
@ -127,15 +127,19 @@ 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 AD are also complete: dead host residue is removed, Slice 8 checkpoints AE 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
sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` sky/day group, weather, synchronization, and AdminEnvirons state.
is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from `CameraPointerInputController` and `FramebufferResizeController` now own raw
the 15,723-line campaign baseline. The 7,419-test camera input/focus/scroll and physical framebuffer publication. Silk,
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second dispatcher, retained-UI, and devtools device edges are reversible,
synchronized nine-stop soak pass. Slice 8 checkpoints EL—input/resize, action transactional, and terminal after disposal; live-session retirement precedes
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints FL—action
routing, settings, resource ownership, ordered composition, routing, settings, 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

View file

@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; 7,341 tests pass / 5 skip. Release builds; 7,477 tests pass / 5 skip.
**Active prerequisite before new M4 subsystem work:** continue the **Active prerequisite before new M4 subsystem work:** continue the
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
@ -125,15 +125,19 @@ 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 AD are also complete: dead host residue is removed, Slice 8 checkpoints AE 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
sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` sky/day group, weather, synchronization, and AdminEnvirons state.
is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from `CameraPointerInputController` and `FramebufferResizeController` now own raw
the 15,723-line campaign baseline. The 7,419-test camera input/focus/scroll and physical framebuffer publication. Silk,
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second dispatcher, retained-UI, and devtools device edges are reversible,
synchronized nine-stop soak pass. Slice 8 checkpoints EL—input/resize, action transactional, and terminal after disposal; live-session retirement precedes
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints FL—action
routing, settings, resource ownership, ordered composition, routing, settings, 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

View file

@ -27,11 +27,12 @@ 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 AD now own native callbacks, live-session complete; Slice 8 checkpoints AE now own native callbacks, live-session
composition, and world-environment state outside the shell. composition, world-environment state, reversible device input/raw pointer,
and physical framebuffer publication 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,330 lines / 192 fields / 67 methods. Remaining handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining
Slice 8 input/settings/composition/shutdown Slice 8 action-routing/settings/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

@ -827,9 +827,13 @@ Current movement/collision ownership:
session generation into `LiveSessionController` plus its focused host and session generation into `LiveSessionController` plus its focused host and
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`. Slice 5 landblock presentation is next. The reconciled `LiveEntityRuntime`. Slices 57 complete landblock presentation and the
ledger and sequence live in `docs/architecture/code-structure.md`. This decision update/render frame graphs. Slice 8 checkpoints AE now own the native
does not reclassify the remaining L.2 conformance work or Track LH. callback shell, live-session composition, world environment, reversible
input/pointer lifetime, and physical framebuffer publication. Gameplay action
routing is the next checkpoint. The reconciled ledger and sequence live in
`docs/architecture/code-structure.md`. This decision does not reclassify the
remaining L.2 conformance work or Track LH.
Full `GameEntity` type aggregation remains a separate, higher-risk target after Full `GameEntity` type aggregation remains a separate, higher-risk target after
the thin-`GameWindow` campaign. The current campaign reuses canonical the thin-`GameWindow` campaign. The current campaign reuses canonical

View file

@ -690,6 +690,16 @@ 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.
Checkpoint E is complete. `CameraPointerInputController` owns raw pointer,
camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
alone publishes physical framebuffer changes. All Silk, dispatcher, retained
UI, and optional devtools device edges are reversible, transactional, and
terminal after disposal. Logical cutoff precedes live-session retirement;
physical detach follows it, so neither copied callbacks nor a failed event
remove can interfere with graceful transport teardown. `GameWindow` is now
4,266 raw lines / 194 fields / 65 methods. Checkpoints FL remain active, with
gameplay action routing next.
### 4.4 Exit criteria ### 4.4 Exit criteria
The campaign is complete when: The campaign is complete when:

View file

@ -218,7 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| 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` |
## 4. Temporary stopgap (TS) — 37 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)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -263,6 +263,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 67 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison | | TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 67 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` | | TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) | | TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
| TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` |
--- ---

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 AD 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 AE 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,12 +79,17 @@ 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 AD now own Final Slice 8 composition/shutdown cleanup is active. Checkpoints AE 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. `GameWindow` is 4,330 lines / 192 fields / 67 methods; AdminEnvirons state. Checkpoint E adds reversible, transactional Silk,
the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings, dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner;
startup/resource transfer, and shutdown checkpoints EL remain. and one physical framebuffer-size publisher. Logical callback cutoff and
live-session retirement precede physical removal, so failed device detach
cannot block graceful transport close. `GameWindow` is 4,266 lines / 194
fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5
skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak,
and closeout checkpoints FL remain.
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,12 +39,15 @@ 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 AD are complete: preserve Slice 6 presentation. Final Slice 8 checkpoints AE 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. `GameWindow` is 4,330 lines / 192 day group, weather, and AdminEnvirons state. Checkpoint E adds terminal,
fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints EL remain transactional ownership for Silk/dispatcher/retained/devtools input, the raw
active. Issue #232 tracks process-residency variance in the soak without camera pointer, focus, and physical framebuffer size. Graceful live-session
retirement now precedes physical callback removal. `GameWindow` is 4,266 lines
/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints FL
remain active. Issue #232 tracks process-residency variance in the soak without
loosening its leak threshold. loosening its leak threshold.
Carried: Carried:

View file

@ -21,7 +21,7 @@ audit.
- [x] C — extract live-session host/reset/binding callbacks and verify the - [x] C — extract live-session host/reset/binding callbacks and verify the
embedded skill formula against named retail. embedded skill formula against named retail.
- [x] D — extract retail world-environment/day/weather behavior. - [x] D — extract retail world-environment/day/weather behavior.
- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer - [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer
resize behind symmetric named subscriptions. resize behind symmetric named subscriptions.
- [ ] F — extract the sole gameplay input-action router plus focused combat and - [ ] F — extract the sole gameplay input-action router plus focused combat and
diagnostic command owners. diagnostic command owners.
@ -497,6 +497,27 @@ this behavior-preserving ownership checkpoint.
- Require rollback after every Nth event add and silence between every shutdown - Require rollback after every Nth event add and silence between every shutdown
stage for raw/retained/devtools device events. stage for raw/retained/devtools device events.
Result: `CameraPointerInputController` now owns raw move, camera-mode cursor,
focus-loss, scroll, and per-mode sensitivity policy; its gameplay-frame edge is
late-bound without resubscription. Silk keyboard/mouse sources,
`InputDispatcher`, retained UI bindings, and the devtools
`QuiescentInputContext` are publish-before-attach, terminal after disposal,
transactional on every side-effecting event add, and retry only the physical
edges that remain pending. Mouse-only hosts retain camera/cursor behavior.
`FramebufferResizeController` is the sole physical framebuffer publisher and
seeds viewport/aspect/camera state from `FramebufferSize`, while logical UI and
frame dimensions remain `Window.Size`. Shutdown now performs no-throw logical
input cutoff, retires the live session, then removes physical callbacks; the
post-session mouse-look release therefore cannot send a final movement packet,
and a bad Silk remove accessor cannot block F653/transport teardown. TS-56
records the carried non-retail camera input scalars. Three corrected-diff review
loops are clean; 66 focused App tests, 9 dispatcher-lifetime tests, 5 Core input
integration tests, the warning-free production Release build, and the complete
Release suite (7,477 pass / 5 intentional skips) pass. `GameWindow` is 4,266
raw lines / 194 fields / 65 methods. No connected gate was required for this
behavior-preserving ownership checkpoint; Checkpoint L retains the final
connected lifecycle and visual gates.
### F — gameplay action and command routing ### F — gameplay action and command routing
- Move the frozen action-priority graph into the sole Fired subscriber. - Move the frozen action-priority graph into the sole Fired subscriber.

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 AD. `GameWindow.cs` moved from the 2026-07-21 baseline of Slice 8 checkpoints AE. `GameWindow.cs` moved from the 2026-07-21 baseline of
15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67 15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65
methods: 11,393 lines (72.5%) were removed without changing accepted gameplay methods: 11,457 lines (72.9%) were removed without changing accepted gameplay
or rendering behavior. or rendering behavior.
| Slice | Ownership moved out | Closeout size | | Slice | Ownership moved out | Closeout size |
@ -17,7 +17,7 @@ or rendering behavior.
| 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 | | 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 |
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
| 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 AD | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 | | 8 AE | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 |
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
@ -34,6 +34,15 @@ composition edges. The named-oracle correction is
`SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and `SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and
full fog/ambient/radar gaps are TS-54/TS-55. full fog/ambient/radar gaps are TS-54/TS-55.
E adds `CameraPointerInputController`, `FramebufferResizeController`, terminal
publish-before-attach Silk/dispatcher/retained input owners, and a quiescent
non-owning input-context view for optional devtools. Mouse-only hosts still
receive raw camera/cursor behavior. Logical callback deactivation runs before
the graceful session close; physical removal and the local mouse-look release
run only after session retirement. This prevents both copied callback reentry
and a failed event accessor from blocking F653/transport teardown. The carried
non-retail post-filter camera scalars are explicitly registered as TS-56.
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`,
@ -90,6 +99,9 @@ review rule live in `docs/architecture/code-structure.md`.
## Accepted verification ## Accepted verification
- Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment
skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent
corrected-diff reviews are clean.
- Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment - Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment
skips; App 3,059/3 skips. Focused environment/boundary tests and all three skips; App 3,059/3 skips. Focused environment/boundary tests and all three
independent corrected-diff reviews are clean. independent corrected-diff reviews are clean.
@ -119,7 +131,7 @@ review rule live in `docs/architecture/code-structure.md`.
## Next work ## Next work
Slice 8 checkpoints EL group the remaining input, settings, resource Slice 8 checkpoints FL group the remaining action routing, settings, resource
ownership, startup, and shutdown wiring and reduce Silk callbacks to ownership, startup, 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

View file

@ -0,0 +1,449 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Input;
/// <summary>
/// Narrow raw-pointer surface. The production adapter keeps the Silk event
/// accessor out of the camera policy and lets failure tests exercise an event
/// add/remove without implementing Silk's complete mouse contract.
/// </summary>
internal interface IRawPointerSurface
{
void AddMouseMove(Action<Vector2> callback);
void RemoveMouseMove(Action<Vector2> callback);
}
internal sealed class SilkRawPointerSurface : IRawPointerSurface
{
private readonly IMouse _mouse;
private Action<Vector2>? _callback;
private readonly Action<IMouse, Vector2> _mouseMove;
public SilkRawPointerSurface(IMouse mouse)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_mouseMove = OnMouseMove;
}
public void AddMouseMove(Action<Vector2> callback)
{
ArgumentNullException.ThrowIfNull(callback);
if (_callback is not null)
throw new InvalidOperationException("The raw mouse surface is already attached.");
// Publish before invoking the custom event accessor: an accessor may
// perform its side effect and then throw, in which case rollback still
// has the exact delegate needed to remove it.
_callback = callback;
_mouse.MouseMove += _mouseMove;
}
public void RemoveMouseMove(Action<Vector2> callback)
{
ArgumentNullException.ThrowIfNull(callback);
if (!ReferenceEquals(_callback, callback))
return;
_mouse.MouseMove -= _mouseMove;
_callback = null;
}
private void OnMouseMove(IMouse _, Vector2 position) =>
_callback?.Invoke(position);
}
internal interface IPointerCursorModeTarget
{
CursorMode CursorMode { get; set; }
}
internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
: IPointerCursorModeTarget
{
private readonly IMouse _mouse = mouse
?? throw new ArgumentNullException(nameof(mouse));
public CursorMode CursorMode
{
get => _mouse.Cursor.CursorMode;
set => _mouse.Cursor.CursorMode = value;
}
}
/// <summary>
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
/// previously lived in GameWindow. Physical event removal is retriable; logical
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
/// re-enter gameplay while shutdown is closing the live session.
/// </summary>
internal sealed class CameraPointerInputController : IDisposable
{
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
private readonly IPointerCursorModeTarget _cursor;
private readonly HostQuiescenceGate _quiescence;
private readonly IInputCaptureSource _capture;
private readonly LocalPlayerModeState _playerMode;
private readonly CameraController _camera;
private readonly ChaseCameraInputState _chase;
private readonly IMouseSource _mouse;
private readonly PointerPositionState _pointer;
private readonly IInputMonotonicClock _clock;
private readonly Action<Vector2> _mouseMoved;
private readonly Action<bool> _cameraModeChanged;
private readonly bool[] _surfaceAttached;
private ResourceShutdownTransaction? _detach;
private GameplayInputFrameController? _gameplayFrame;
private bool _cameraAttached;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
private float _flySensitivity = 1f;
private float _orbitSensitivity = 1f;
public CameraPointerInputController(
IReadOnlyList<IRawPointerSurface> surfaces,
IPointerCursorModeTarget cursor,
HostQuiescenceGate quiescence,
IInputCaptureSource capture,
LocalPlayerModeState playerMode,
CameraController camera,
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer,
IInputMonotonicClock clock)
{
_surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces));
if (_surfaces.Any(static surface => surface is null))
throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces));
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_surfaceAttached = new bool[_surfaces.Count];
_mouseMoved = OnMouseMoved;
_cameraModeChanged = OnCameraModeChanged;
}
public static CameraPointerInputController Create(
IReadOnlyList<IMouse> mice,
HostQuiescenceGate quiescence,
IInputCaptureSource capture,
LocalPlayerModeState playerMode,
CameraController camera,
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer,
IInputMonotonicClock clock)
{
ArgumentNullException.ThrowIfNull(mice);
if (mice.Count == 0)
throw new ArgumentException("At least one mouse is required.", nameof(mice));
return new CameraPointerInputController(
mice.Select(static candidate =>
(IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(),
new SilkPointerCursorModeTarget(mice[0]),
quiescence,
capture,
playerMode,
camera,
chase,
mouse,
pointer,
clock);
}
public bool IsDisposalComplete =>
!_cameraAttached && _surfaceAttached.All(static attached => !attached);
public float ActiveSensitivity
{
get
{
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
return _chase.Sensitivity;
if (_camera.IsFlyMode)
return _flySensitivity;
return _orbitSensitivity;
}
}
public bool RmbOrbitHeld => _chase.RmbOrbitHeld;
public void AttachRaw()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException("Raw pointer attachment has already started.");
_attachStarted = true;
try
{
for (int i = 0; i < _surfaces.Count; i++)
{
_surfaceAttached[i] = true;
_surfaces[i].AddMouseMove(_mouseMoved);
}
_cameraAttached = true;
_camera.ModeChanged += _cameraModeChanged;
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Raw pointer registration and rollback both failed.",
new InvalidOperationException(
"Raw pointer registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Raw pointer registration failed and was rolled back.",
attachError);
}
}
/// <summary>
/// Completes the deliberate two-phase composition edge. Binding the frame
/// owner never resubscribes or changes event priority.
/// </summary>
public void BindGameplayFrame(GameplayInputFrameController gameplayFrame)
{
ArgumentNullException.ThrowIfNull(gameplayFrame);
if (_gameplayFrame is not null
&& !ReferenceEquals(_gameplayFrame, gameplayFrame))
{
throw new InvalidOperationException(
"The raw pointer owner is already bound to another gameplay frame.");
}
_gameplayFrame = gameplayFrame;
}
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
{
ArgumentNullException.ThrowIfNull(gameplayFrame);
if (ReferenceEquals(_gameplayFrame, gameplayFrame))
_gameplayFrame = null;
}
public void HandleFocusChanged(bool focused)
{
if (!focused)
_gameplayFrame?.EndMouseLook();
}
public void HandleScroll(InputAction action)
{
float direction = action switch
{
InputAction.ScrollUp => 1f,
InputAction.ScrollDown => -1f,
_ => throw new ArgumentOutOfRangeException(nameof(action)),
};
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
{
if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null)
_chase.Retail.AdjustDistance(-direction * 0.8f);
else
_chase.Legacy?.AdjustDistance(-direction * 0.8f);
}
else if (!_camera.IsFlyMode)
{
_camera.Orbit.Distance = Math.Clamp(
_camera.Orbit.Distance - direction * 20f,
50f,
2000f);
}
}
public string AdjustSensitivity(float factor)
{
string mode;
float current;
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
{
mode = "Chase";
current = _chase.Sensitivity;
}
else if (_camera.IsFlyMode)
{
mode = "Fly";
current = _flySensitivity;
}
else
{
mode = "Orbit";
current = _orbitSensitivity;
}
float next = MathF.Min(3f, MathF.Max(0.005f, current * factor));
if (mode == "Chase")
_chase.Sensitivity = next;
else if (mode == "Fly")
_flySensitivity = next;
else
_orbitSensitivity = next;
return $"{mode} sens {next:F3}x";
}
/// <summary>
/// Immediate callback cutoff used before the live-session close. The
/// gameplay mouse-look transition is retained until the session has
/// retired so it cannot publish one last movement packet.
/// </summary>
public void Deactivate()
{
Interlocked.Exchange(ref _active, 0);
_chase.RmbOrbitHeld = false;
}
/// <summary>Release presentation state only after live-session retirement.</summary>
public void ReleaseMouseLookAfterSessionRetirement() =>
_gameplayFrame?.EndMouseLook();
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnMouseMoved(Vector2 position) =>
_quiescence.Invoke(() => ProcessMouseMove(position));
private void ProcessMouseMove(Vector2 position)
{
if (Volatile.Read(ref _active) == 0)
return;
if (_capture.WantCaptureMouse)
{
_pointer.X = position.X;
_pointer.Y = position.Y;
return;
}
float dx = position.X - _pointer.X;
float dy = position.Y - _pointer.Y;
if (_playerMode.IsPlayerMode && _camera.IsChaseMode
&& _chase.Legacy is not null)
{
float sensitivity = _chase.Sensitivity;
if (_gameplayFrame?.MouseLookActive == true)
{
_gameplayFrame.QueueRawMouseDelta(dx, dy);
}
else if (_chase.RmbOrbitHeld)
{
if (CameraDiagnostics.UseRetailChaseCamera
&& _chase.Retail is not null)
{
var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta(
rawX: dx,
rawY: dy,
weight: 0.5f,
nowSec: _clock.NowSeconds);
_chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity;
_chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity);
}
else
{
_chase.Legacy.YawOffset -= dx * 0.004f * sensitivity;
_chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity);
}
}
}
else if (_camera.IsFlyMode)
{
_camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity);
}
else if (_mouse.IsHeld(MouseButton.Left))
{
_camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity;
_camera.Orbit.Pitch = Math.Clamp(
_camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity,
0.1f,
1.5f);
}
_pointer.X = position.X;
_pointer.Y = position.Y;
}
private void OnCameraModeChanged(bool _) =>
_quiescence.Invoke(ApplyCursorForCameraMode);
private void ApplyCursorForCameraMode()
{
if (Volatile.Read(ref _active) == 0)
return;
if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode)
_gameplayFrame.EndMouseLook();
_cursor.CursorMode = _camera.IsFlyMode
? CursorMode.Raw
: CursorMode.Normal;
}
private ResourceShutdownTransaction EnsureDetachTransaction()
{
if (_detach is not null)
return _detach;
var operations = new List<ResourceShutdownOperation>
{
new("camera mode", RemoveCameraMode),
};
for (int i = _surfaces.Count - 1; i >= 0; i--)
{
int index = i;
operations.Add(new ResourceShutdownOperation(
$"raw mouse {index}",
() => RemoveSurface(index)));
}
_detach = new ResourceShutdownTransaction(
new ResourceShutdownStage("raw pointer callbacks", operations.ToArray()));
return _detach;
}
private void RemoveCameraMode()
{
if (!_cameraAttached)
return;
_camera.ModeChanged -= _cameraModeChanged;
_cameraAttached = false;
}
private void RemoveSurface(int index)
{
if (!_surfaceAttached[index])
return;
_surfaces[index].RemoveMouseMove(_mouseMoved);
_surfaceAttached[index] = false;
}
}

View file

@ -43,6 +43,13 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
_dispatcher = dispatcher; _dispatcher = dispatcher;
} }
public void Unbind(InputDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
if (ReferenceEquals(_dispatcher, dispatcher))
_dispatcher = null;
}
public FlyCameraInput CaptureFly() public FlyCameraInput CaptureFly()
{ {
if (_dispatcher is not { } dispatcher) if (_dispatcher is not { } dispatcher)

View file

@ -31,6 +31,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
_dispatcher = dispatcher; _dispatcher = dispatcher;
} }
public void Unbind(InputDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
if (ReferenceEquals(_dispatcher, dispatcher))
_dispatcher = null;
}
public MovementInput Capture() public MovementInput Capture()
{ {
// Devtools owns the whole gameplay keyboard while active, including // Devtools owns the whole gameplay keyboard while active, including

View file

@ -52,23 +52,3 @@ internal sealed class CompositeInputCaptureSource : IInputCaptureSource
public bool DevToolsWantCaptureKeyboard => public bool DevToolsWantCaptureKeyboard =>
_devTools.WantCaptureKeyboard; _devTools.WantCaptureKeyboard;
} }
internal sealed class DelegateInputCaptureSource : IInputCaptureSource
{
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
public DelegateInputCaptureSource(
Func<bool> wantCaptureMouse,
Func<bool> wantCaptureKeyboard)
{
_wantCaptureMouse = wantCaptureMouse
?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard
?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
}
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
public bool DevToolsWantCaptureKeyboard => false;
}

View file

@ -0,0 +1,486 @@
using System.Numerics;
using AcDream.App.Rendering;
using Silk.NET.Input;
namespace AcDream.App.Input;
/// <summary>
/// Non-owning input-context view for third-party frontends such as Silk's
/// ImGuiController. It interposes named device relays so frontend callbacks can
/// be silenced before a long live-session close and physically detached later,
/// without disposing the canonical Silk input context.
/// </summary>
internal sealed class QuiescentInputContext : IInputContext
{
private readonly IInputContext _inner;
private readonly HostQuiescenceGate _quiescence;
private readonly QuiescentKeyboard[] _keyboards;
private readonly QuiescentMouse[] _mice;
private readonly Action<IInputDevice, bool> _connectionChanged;
private Action<IInputDevice, bool>? _connectionSubscribers;
private ResourceShutdownTransaction? _detach;
private bool _connectionAttached;
private int _active;
private bool _activationStarted;
private int _disposeRequested;
public QuiescentInputContext(
IInputContext inner,
HostQuiescenceGate quiescence)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_keyboards = inner.Keyboards
.Select(keyboard => new QuiescentKeyboard(keyboard, quiescence))
.ToArray();
_mice = inner.Mice
.Select(mouse => new QuiescentMouse(mouse, quiescence))
.ToArray();
_connectionChanged = OnConnectionChanged;
}
public nint Handle => _inner.Handle;
public IReadOnlyList<IGamepad> Gamepads => _inner.Gamepads;
public IReadOnlyList<IJoystick> Joysticks => _inner.Joysticks;
public IReadOnlyList<IKeyboard> Keyboards => _keyboards;
public IReadOnlyList<IMouse> Mice => _mice;
public IReadOnlyList<IInputDevice> OtherDevices => _inner.OtherDevices;
public event Action<IInputDevice, bool>? ConnectionChanged
{
add
{
if (value is null) return;
ThrowIfDisposed();
_connectionSubscribers += value;
if (_connectionAttached) return;
_connectionAttached = true;
_inner.ConnectionChanged += _connectionChanged;
}
remove
{
if (value is null) return;
_connectionSubscribers -= value;
if (_connectionSubscribers is not null || !_connectionAttached) return;
_inner.ConnectionChanged -= _connectionChanged;
_connectionAttached = false;
}
}
public bool IsDisposalComplete =>
!_connectionAttached
&& _keyboards.All(static keyboard => keyboard.IsDisposalComplete)
&& _mice.All(static mouse => mouse.IsDisposalComplete);
/// <summary>
/// Opens delivery only after the third-party frontend constructor has
/// finished installing every callback. An event raised reentrantly from an
/// add accessor therefore cannot enter a partially constructed frontend.
/// </summary>
public void Activate()
{
ThrowIfDisposed();
if (_activationStarted)
throw new InvalidOperationException(
"Frontend input activation has already been attempted.");
_activationStarted = true;
foreach (QuiescentKeyboard keyboard in _keyboards)
keyboard.Activate();
foreach (QuiescentMouse mouse in _mice)
mouse.Activate();
Volatile.Write(ref _active, 1);
}
public void Deactivate()
{
Interlocked.Exchange(ref _active, 0);
foreach (QuiescentKeyboard keyboard in _keyboards)
keyboard.Deactivate();
foreach (QuiescentMouse mouse in _mice)
mouse.Deactivate();
}
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
foreach (QuiescentKeyboard keyboard in _keyboards)
keyboard.RequestDisposal();
foreach (QuiescentMouse mouse in _mice)
mouse.RequestDisposal();
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
private void OnConnectionChanged(IInputDevice device, bool connected) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
_connectionSubscribers?.Invoke(device, connected);
});
private ResourceShutdownTransaction EnsureDetachTransaction()
{
if (_detach is not null)
return _detach;
var operations = new List<ResourceShutdownOperation>();
for (int i = _mice.Length - 1; i >= 0; i--)
{
int index = i;
operations.Add(new ResourceShutdownOperation(
$"frontend mouse {index}",
_mice[index].Dispose));
}
for (int i = _keyboards.Length - 1; i >= 0; i--)
{
int index = i;
operations.Add(new ResourceShutdownOperation(
$"frontend keyboard {index}",
_keyboards[index].Dispose));
}
operations.Add(new ResourceShutdownOperation(
"frontend connection event",
RemoveConnectionChanged));
_detach = new ResourceShutdownTransaction(
new ResourceShutdownStage("frontend input callbacks", operations.ToArray()));
return _detach;
}
private void RemoveConnectionChanged()
{
if (!_connectionAttached) return;
_inner.ConnectionChanged -= _connectionChanged;
_connectionAttached = false;
_connectionSubscribers = null;
}
private sealed class QuiescentKeyboard : IKeyboard, IDisposable
{
private readonly IKeyboard _inner;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<IKeyboard, Key, int> _downRelay;
private readonly Action<IKeyboard, Key, int> _upRelay;
private readonly Action<IKeyboard, char> _charRelay;
private Action<IKeyboard, Key, int>? _downSubscribers;
private Action<IKeyboard, Key, int>? _upSubscribers;
private Action<IKeyboard, char>? _charSubscribers;
private readonly bool[] _attached = new bool[3];
private ResourceShutdownTransaction? _detach;
private int _disposeRequested;
private int _active;
public QuiescentKeyboard(IKeyboard inner, HostQuiescenceGate quiescence)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_quiescence = quiescence;
_downRelay = OnDown;
_upRelay = OnUp;
_charRelay = OnChar;
}
public string Name => _inner.Name;
public int Index => _inner.Index;
public bool IsConnected => _inner.IsConnected;
public IReadOnlyList<Key> SupportedKeys => _inner.SupportedKeys;
public string ClipboardText
{
get => _inner.ClipboardText;
set => _inner.ClipboardText = value;
}
public event Action<IKeyboard, Key, int>? KeyDown
{
add
{
if (value is null) return;
ThrowIfDisposed();
_downSubscribers += value;
if (_attached[0]) return;
_attached[0] = true;
_inner.KeyDown += _downRelay;
}
remove
{
if (value is null) return;
_downSubscribers -= value;
if (_downSubscribers is not null || !_attached[0]) return;
_inner.KeyDown -= _downRelay;
_attached[0] = false;
}
}
public event Action<IKeyboard, Key, int>? KeyUp
{
add
{
if (value is null) return;
ThrowIfDisposed();
_upSubscribers += value;
if (_attached[1]) return;
_attached[1] = true;
_inner.KeyUp += _upRelay;
}
remove
{
if (value is null) return;
_upSubscribers -= value;
if (_upSubscribers is not null || !_attached[1]) return;
_inner.KeyUp -= _upRelay;
_attached[1] = false;
}
}
public event Action<IKeyboard, char>? KeyChar
{
add
{
if (value is null) return;
ThrowIfDisposed();
_charSubscribers += value;
if (_attached[2]) return;
_attached[2] = true;
_inner.KeyChar += _charRelay;
}
remove
{
if (value is null) return;
_charSubscribers -= value;
if (_charSubscribers is not null || !_attached[2]) return;
_inner.KeyChar -= _charRelay;
_attached[2] = false;
}
}
public bool IsDisposalComplete => _attached.All(static value => !value);
public bool IsKeyPressed(Key key) => _inner.IsKeyPressed(key);
public bool IsScancodePressed(int scancode) => _inner.IsScancodePressed(scancode);
public void BeginInput() => _inner.BeginInput();
public void EndInput() => _inner.EndInput();
public void Activate()
{
ThrowIfDisposed();
Volatile.Write(ref _active, 1);
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void RequestDisposal() =>
Interlocked.Exchange(ref _disposeRequested, 1);
public void Dispose()
{
RequestDisposal();
Deactivate();
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("frontend keyboard callbacks",
[
new("character", () => Remove(2)),
new("up", () => Remove(1)),
new("down", () => Remove(0)),
]));
_detach.CompleteOrThrow();
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
private void OnDown(IKeyboard _, Key key, int scanCode) =>
Invoke(() => _downSubscribers?.Invoke(this, key, scanCode));
private void OnUp(IKeyboard _, Key key, int scanCode) =>
Invoke(() => _upSubscribers?.Invoke(this, key, scanCode));
private void OnChar(IKeyboard _, char value) =>
Invoke(() => _charSubscribers?.Invoke(this, value));
private void Invoke(Action callback) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
callback();
});
private void Remove(int index)
{
if (!_attached[index]) return;
switch (index)
{
case 0: _inner.KeyDown -= _downRelay; _downSubscribers = null; break;
case 1: _inner.KeyUp -= _upRelay; _upSubscribers = null; break;
case 2: _inner.KeyChar -= _charRelay; _charSubscribers = null; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
}
private sealed class QuiescentMouse : IMouse, IDisposable
{
private readonly IMouse _inner;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<IMouse, MouseButton> _downRelay;
private readonly Action<IMouse, MouseButton> _upRelay;
private readonly Action<IMouse, MouseButton, Vector2> _clickRelay;
private readonly Action<IMouse, MouseButton, Vector2> _doubleClickRelay;
private readonly Action<IMouse, Vector2> _moveRelay;
private readonly Action<IMouse, ScrollWheel> _scrollRelay;
private Action<IMouse, MouseButton>? _downSubscribers;
private Action<IMouse, MouseButton>? _upSubscribers;
private Action<IMouse, MouseButton, Vector2>? _clickSubscribers;
private Action<IMouse, MouseButton, Vector2>? _doubleClickSubscribers;
private Action<IMouse, Vector2>? _moveSubscribers;
private Action<IMouse, ScrollWheel>? _scrollSubscribers;
private readonly bool[] _attached = new bool[6];
private ResourceShutdownTransaction? _detach;
private int _disposeRequested;
private int _active;
public QuiescentMouse(IMouse inner, HostQuiescenceGate quiescence)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_quiescence = quiescence;
_downRelay = OnDown;
_upRelay = OnUp;
_clickRelay = OnClick;
_doubleClickRelay = OnDoubleClick;
_moveRelay = OnMove;
_scrollRelay = OnScroll;
}
public string Name => _inner.Name;
public int Index => _inner.Index;
public bool IsConnected => _inner.IsConnected;
public IReadOnlyList<MouseButton> SupportedButtons => _inner.SupportedButtons;
public IReadOnlyList<ScrollWheel> ScrollWheels => _inner.ScrollWheels;
public Vector2 Position { get => _inner.Position; set => _inner.Position = value; }
public ICursor Cursor => _inner.Cursor;
public int DoubleClickTime { get => _inner.DoubleClickTime; set => _inner.DoubleClickTime = value; }
public int DoubleClickRange { get => _inner.DoubleClickRange; set => _inner.DoubleClickRange = value; }
public event Action<IMouse, MouseButton> MouseDown
{
add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay);
remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay);
}
public event Action<IMouse, MouseButton> MouseUp
{
add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay);
remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay);
}
public event Action<IMouse, MouseButton, Vector2> Click
{
add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay);
remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay);
}
public event Action<IMouse, MouseButton, Vector2> DoubleClick
{
add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay);
remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay);
}
public event Action<IMouse, Vector2> MouseMove
{
add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay);
remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay);
}
public event Action<IMouse, ScrollWheel> Scroll
{
add => Add(ref _scrollSubscribers, value, 5, () => _inner.Scroll += _scrollRelay);
remove => RemoveSubscriber(ref _scrollSubscribers, value, 5, () => _inner.Scroll -= _scrollRelay);
}
public bool IsDisposalComplete => _attached.All(static value => !value);
public bool IsButtonPressed(MouseButton button) => _inner.IsButtonPressed(button);
public void Activate()
{
ThrowIfDisposed();
Volatile.Write(ref _active, 1);
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void RequestDisposal() =>
Interlocked.Exchange(ref _disposeRequested, 1);
public void Dispose()
{
RequestDisposal();
Deactivate();
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("frontend mouse callbacks",
[
new("scroll", () => RemovePhysical(5)),
new("move", () => RemovePhysical(4)),
new("double click", () => RemovePhysical(3)),
new("click", () => RemovePhysical(2)),
new("up", () => RemovePhysical(1)),
new("down", () => RemovePhysical(0)),
]));
_detach.CompleteOrThrow();
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
private void OnDown(IMouse _, MouseButton button) =>
Invoke(() => _downSubscribers?.Invoke(this, button));
private void OnUp(IMouse _, MouseButton button) =>
Invoke(() => _upSubscribers?.Invoke(this, button));
private void OnClick(IMouse _, MouseButton button, Vector2 position) =>
Invoke(() => _clickSubscribers?.Invoke(this, button, position));
private void OnDoubleClick(IMouse _, MouseButton button, Vector2 position) =>
Invoke(() => _doubleClickSubscribers?.Invoke(this, button, position));
private void OnMove(IMouse _, Vector2 position) =>
Invoke(() => _moveSubscribers?.Invoke(this, position));
private void OnScroll(IMouse _, ScrollWheel scroll) =>
Invoke(() => _scrollSubscribers?.Invoke(this, scroll));
private void Invoke(Action callback) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
callback();
});
private void Add<T>(ref T? subscribers, T value, int index, Action attach)
where T : Delegate
{
ThrowIfDisposed();
subscribers = (T?)Delegate.Combine(subscribers, value);
if (_attached[index]) return;
_attached[index] = true;
attach();
}
private void RemoveSubscriber<T>(
ref T? subscribers,
T value,
int index,
Action detach)
where T : Delegate
{
subscribers = (T?)Delegate.Remove(subscribers, value);
if (subscribers is not null || !_attached[index]) return;
detach();
_attached[index] = false;
}
private void RemovePhysical(int index)
{
if (!_attached[index]) return;
switch (index)
{
case 0: _inner.MouseDown -= _downRelay; _downSubscribers = null; break;
case 1: _inner.MouseUp -= _upRelay; _upSubscribers = null; break;
case 2: _inner.Click -= _clickRelay; _clickSubscribers = null; break;
case 3: _inner.DoubleClick -= _doubleClickRelay; _doubleClickSubscribers = null; break;
case 4: _inner.MouseMove -= _moveRelay; _moveSubscribers = null; break;
case 5: _inner.Scroll -= _scrollRelay; _scrollSubscribers = null; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
}
}

View file

@ -1,46 +1,218 @@
using System; using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using Silk.NET.Input; using Silk.NET.Input;
namespace AcDream.App.Input; namespace AcDream.App.Input;
/// <summary> internal interface IKeyboardEventSurface
/// Bridges a Silk.NET <see cref="IKeyboard"/> to the test-fakeable {
/// <see cref="IKeyboardSource"/> interface used by void AddKeyDown(Action<Key> callback);
/// <see cref="InputDispatcher"/>. K.1a wiring: GameWindow constructs void RemoveKeyDown(Action<Key> callback);
/// one of these wrapping the first keyboard from void AddKeyUp(Action<Key> callback);
/// <c>IInputContext.Keyboards</c>; the dispatcher fans events out to void RemoveKeyUp(Action<Key> callback);
/// subscribers without ever touching Silk types directly. bool IsKeyPressed(Key key);
/// </summary> }
public sealed class SilkKeyboardSource : IKeyboardSource
internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface
{ {
private readonly IKeyboard _keyboard; private readonly IKeyboard _keyboard;
private Action<Key>? _keyDownCallback;
private Action<Key>? _keyUpCallback;
private readonly Action<IKeyboard, Key, int> _keyDown;
private readonly Action<IKeyboard, Key, int> _keyUp;
public SilkKeyboardEventSurface(IKeyboard keyboard)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_keyDown = OnKeyDown;
_keyUp = OnKeyUp;
}
public void AddKeyDown(Action<Key> callback)
{
ArgumentNullException.ThrowIfNull(callback);
_keyDownCallback = callback;
_keyboard.KeyDown += _keyDown;
}
public void RemoveKeyDown(Action<Key> callback)
{
if (!ReferenceEquals(_keyDownCallback, callback))
return;
_keyboard.KeyDown -= _keyDown;
_keyDownCallback = null;
}
public void AddKeyUp(Action<Key> callback)
{
ArgumentNullException.ThrowIfNull(callback);
_keyUpCallback = callback;
_keyboard.KeyUp += _keyUp;
}
public void RemoveKeyUp(Action<Key> callback)
{
if (!ReferenceEquals(_keyUpCallback, callback))
return;
_keyboard.KeyUp -= _keyUp;
_keyUpCallback = null;
}
public bool IsKeyPressed(Key key) => _keyboard.IsKeyPressed(key);
private void OnKeyDown(IKeyboard _, Key key, int __) =>
_keyDownCallback?.Invoke(key);
private void OnKeyUp(IKeyboard _, Key key, int __) =>
_keyUpCallback?.Invoke(key);
}
/// <summary>
/// Reversible Silk keyboard bridge. Production publishes the detached owner
/// before Attach so a side-effecting event accessor can never orphan a
/// subscription. Logical deactivation makes copied delegates inert while
/// physical removal is retried.
/// </summary>
public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
{
private readonly IKeyboardEventSurface _surface;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<Key> _keyDown;
private readonly Action<Key> _keyUp;
private readonly bool[] _attached = new bool[2];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public event Action<Key, ModifierMask>? KeyDown; public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp; public event Action<Key, ModifierMask>? KeyUp;
public SilkKeyboardSource(IKeyboard keyboard) private SilkKeyboardSource(
IKeyboardEventSurface surface,
HostQuiescenceGate quiescence)
{ {
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); _surface = surface ?? throw new ArgumentNullException(nameof(surface));
_keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers()); _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers()); _keyDown = OnKeyDown;
_keyUp = OnKeyUp;
} }
public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key); internal static SilkKeyboardSource CreateDetached(
IKeyboard keyboard,
HostQuiescenceGate quiescence) =>
new(
new SilkKeyboardEventSurface(keyboard),
quiescence);
internal static SilkKeyboardSource CreateDetached(
IKeyboardEventSurface surface,
HostQuiescenceGate quiescence) =>
new(surface, quiescence);
public bool IsDisposalComplete => _attached.All(static value => !value);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException("Keyboard source attachment has already started.");
_attachStarted = true;
try
{
_attached[0] = true;
_surface.AddKeyDown(_keyDown);
_attached[1] = true;
_surface.AddKeyUp(_keyUp);
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
RollBackOrThrow(attachError);
}
}
public bool IsHeld(Key key) => _surface.IsKeyPressed(key);
public ModifierMask CurrentModifiers => ReadModifiers(); public ModifierMask CurrentModifiers => ReadModifiers();
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnKeyDown(Key key) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
KeyDown?.Invoke(key, ReadModifiers());
});
private void OnKeyUp(Key key) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
KeyUp?.Invoke(key, ReadModifiers());
});
private ModifierMask ReadModifiers() private ModifierMask ReadModifiers()
{ {
ModifierMask m = ModifierMask.None; ModifierMask modifiers = ModifierMask.None;
if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight)) if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight))
m |= ModifierMask.Shift; modifiers |= ModifierMask.Shift;
if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight)) if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight))
m |= ModifierMask.Ctrl; modifiers |= ModifierMask.Ctrl;
if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight)) if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight))
m |= ModifierMask.Alt; modifiers |= ModifierMask.Alt;
if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight)) if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight))
m |= ModifierMask.Win; modifiers |= ModifierMask.Win;
return m; return modifiers;
}
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("keyboard source callbacks",
[
new("key up", () => Remove(1)),
new("key down", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index])
return;
if (index == 1)
_surface.RemoveKeyUp(_keyUp);
else
_surface.RemoveKeyDown(_keyDown);
_attached[index] = false;
}
private void RollBackOrThrow(Exception attachError)
{
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Keyboard source registration and rollback both failed.",
new InvalidOperationException(
"Keyboard source registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Keyboard source registration failed and was rolled back.",
attachError);
} }
} }

View file

@ -1,92 +1,295 @@
using System; using System.Numerics;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
using Silk.NET.Input; using Silk.NET.Input;
namespace AcDream.App.Input; namespace AcDream.App.Input;
/// <summary> internal interface IMouseEventSurface
/// Bridges a Silk.NET <see cref="IMouse"/> + the active ImGui IO {
/// (for <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>) void AddMouseDown(Action<MouseButton> callback);
/// to the test-fakeable <see cref="IMouseSource"/> used by void RemoveMouseDown(Action<MouseButton> callback);
/// <see cref="InputDispatcher"/>. void AddMouseUp(Action<MouseButton> callback);
/// void RemoveMouseUp(Action<MouseButton> callback);
/// <para> void AddMouseMove(Action<Vector2> callback);
/// Production receives a typed capture source, keeping retained-UI and void RemoveMouseMove(Action<Vector2> callback);
/// developer-UI ownership outside the Silk adapter. The public delegate void AddScroll(Action<float> callback);
/// overload remains as a compatibility seam for standalone consumers. void RemoveScroll(Action<float> callback);
/// </para> bool IsButtonPressed(MouseButton button);
/// </summary> }
public sealed class SilkMouseSource : IMouseSource
internal sealed class SilkMouseEventSurface : IMouseEventSurface
{ {
private readonly IMouse _mouse; private readonly IMouse _mouse;
private Action<MouseButton>? _downCallback;
private Action<MouseButton>? _upCallback;
private Action<Vector2>? _moveCallback;
private Action<float>? _scrollCallback;
private readonly Action<IMouse, MouseButton> _down;
private readonly Action<IMouse, MouseButton> _up;
private readonly Action<IMouse, Vector2> _move;
private readonly Action<IMouse, ScrollWheel> _scroll;
public SilkMouseEventSurface(IMouse mouse)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_down = OnDown;
_up = OnUp;
_move = OnMove;
_scroll = OnScroll;
}
public void AddMouseDown(Action<MouseButton> callback)
{
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseDown += _down;
}
public void RemoveMouseDown(Action<MouseButton> callback)
{
if (!ReferenceEquals(_downCallback, callback)) return;
_mouse.MouseDown -= _down;
_downCallback = null;
}
public void AddMouseUp(Action<MouseButton> callback)
{
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseUp += _up;
}
public void RemoveMouseUp(Action<MouseButton> callback)
{
if (!ReferenceEquals(_upCallback, callback)) return;
_mouse.MouseUp -= _up;
_upCallback = null;
}
public void AddMouseMove(Action<Vector2> callback)
{
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseMove += _move;
}
public void RemoveMouseMove(Action<Vector2> callback)
{
if (!ReferenceEquals(_moveCallback, callback)) return;
_mouse.MouseMove -= _move;
_moveCallback = null;
}
public void AddScroll(Action<float> callback)
{
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.Scroll += _scroll;
}
public void RemoveScroll(Action<float> callback)
{
if (!ReferenceEquals(_scrollCallback, callback)) return;
_mouse.Scroll -= _scroll;
_scrollCallback = null;
}
public bool IsButtonPressed(MouseButton button) => _mouse.IsButtonPressed(button);
private void OnDown(IMouse _, MouseButton button) => _downCallback?.Invoke(button);
private void OnUp(IMouse _, MouseButton button) => _upCallback?.Invoke(button);
private void OnMove(IMouse _, Vector2 position) => _moveCallback?.Invoke(position);
private void OnScroll(IMouse _, ScrollWheel scroll) => _scrollCallback?.Invoke(scroll.Y);
}
/// <summary>Reversible Silk mouse bridge with immediate logical cutoff.</summary>
public sealed class SilkMouseSource : IMouseSource, IDisposable
{
private readonly IMouseEventSurface _surface;
private readonly IInputCaptureSource _capture; private readonly IInputCaptureSource _capture;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<MouseButton> _mouseDown;
private readonly Action<MouseButton> _mouseUp;
private readonly Action<Vector2> _mouseMove;
private readonly Action<float> _scroll;
private readonly bool[] _attached = new bool[4];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
private float _lastX; private float _lastX;
private float _lastY; private float _lastY;
private bool _haveLastPos; private bool _haveLastPosition;
public event Action<MouseButton, ModifierMask>? MouseDown; public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp; public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove; public event Action<float, float>? MouseMove;
public event Action<float>? Scroll; public event Action<float>? Scroll;
/// <summary>The keyboard source that owns the current modifier mask, so
/// mouse events and keyboard events resolve identical chords.</summary>
public IKeyboardSource? ModifierSource { get; set; } public IKeyboardSource? ModifierSource { get; set; }
internal SilkMouseSource( private SilkMouseSource(
IMouseEventSurface surface,
IInputCaptureSource capture,
IKeyboardSource? modifierSource,
HostQuiescenceGate quiescence)
{
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
ModifierSource = modifierSource;
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_mouseDown = OnMouseDown;
_mouseUp = OnMouseUp;
_mouseMove = OnMouseMove;
_scroll = OnScroll;
}
internal static SilkMouseSource CreateDetached(
IMouse mouse, IMouse mouse,
IInputCaptureSource capture, IInputCaptureSource capture,
IKeyboardSource modifierSource) IKeyboardSource? modifierSource,
{ HostQuiescenceGate quiescence) =>
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); new(
_capture = capture ?? throw new ArgumentNullException(nameof(capture)); new SilkMouseEventSurface(mouse),
ModifierSource = modifierSource capture,
?? throw new ArgumentNullException(nameof(modifierSource)); modifierSource,
Subscribe(); quiescence);
}
public SilkMouseSource( internal static SilkMouseSource CreateDetached(
IMouse mouse, IMouseEventSurface surface,
Func<bool> wantCaptureMouse, IInputCaptureSource capture,
Func<bool> wantCaptureKeyboard) IKeyboardSource? modifierSource,
{ HostQuiescenceGate quiescence) =>
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); new(surface, capture, modifierSource, quiescence);
_capture = new DelegateInputCaptureSource(
wantCaptureMouse,
wantCaptureKeyboard);
Subscribe();
}
private void Subscribe() public bool IsDisposalComplete => _attached.All(static value => !value);
public void Attach()
{ {
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers()); ObjectDisposedException.ThrowIf(
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers()); Volatile.Read(ref _disposeRequested) != 0,
_mouse.MouseMove += (_, pos) => this);
if (_attachStarted)
throw new InvalidOperationException("Mouse source attachment has already started.");
_attachStarted = true;
try
{ {
float dx, dy; _attached[0] = true;
if (_haveLastPos) _surface.AddMouseDown(_mouseDown);
{ _attached[1] = true;
dx = pos.X - _lastX; _surface.AddMouseUp(_mouseUp);
dy = pos.Y - _lastY; _attached[2] = true;
} _surface.AddMouseMove(_mouseMove);
else _attached[3] = true;
{ _surface.AddScroll(_scroll);
dx = 0f; Volatile.Write(ref _active, 1);
dy = 0f; }
_haveLastPos = true; catch (Exception attachError)
} {
_lastX = pos.X; Deactivate();
_lastY = pos.Y; RollBackOrThrow(attachError);
MouseMove?.Invoke(dx, dy); }
}; }
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
public bool IsHeld(MouseButton button) => _surface.IsButtonPressed(button);
public bool WantCaptureMouse => _capture.WantCaptureMouse;
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
} }
private ModifierMask ReadModifiers() => private ModifierMask ReadModifiers() =>
ModifierSource?.CurrentModifiers ?? ModifierMask.None; ModifierSource?.CurrentModifiers ?? ModifierMask.None;
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button); private void OnMouseDown(MouseButton button) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
MouseDown?.Invoke(button, ReadModifiers());
});
public bool WantCaptureMouse => _capture.WantCaptureMouse; private void OnMouseUp(MouseButton button) =>
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; _quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
MouseUp?.Invoke(button, ReadModifiers());
});
private void OnMouseMove(Vector2 position) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) == 0)
return;
float dx = 0f;
float dy = 0f;
if (_haveLastPosition)
{
dx = position.X - _lastX;
dy = position.Y - _lastY;
}
else
{
_haveLastPosition = true;
}
_lastX = position.X;
_lastY = position.Y;
MouseMove?.Invoke(dx, dy);
});
private void OnScroll(float delta) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
Scroll?.Invoke(delta);
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("mouse source callbacks",
[
new("scroll", () => Remove(3)),
new("move", () => Remove(2)),
new("up", () => Remove(1)),
new("down", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index])
return;
switch (index)
{
case 0: _surface.RemoveMouseDown(_mouseDown); break;
case 1: _surface.RemoveMouseUp(_mouseUp); break;
case 2: _surface.RemoveMouseMove(_mouseMove); break;
case 3: _surface.RemoveScroll(_scroll); break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
private void RollBackOrThrow(Exception attachError)
{
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Mouse source registration and rollback both failed.",
new InvalidOperationException(
"Mouse source registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Mouse source registration failed and was rolled back.",
attachError);
}
} }

View file

@ -0,0 +1,118 @@
using AcDream.App.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface IFramebufferViewportTarget
{
void ResizeViewport(int width, int height);
}
internal sealed class SilkFramebufferViewportTarget(GL gl)
: IFramebufferViewportTarget
{
private readonly GL _gl = gl ?? throw new ArgumentNullException(nameof(gl));
public void ResizeViewport(int width, int height) =>
_gl.Viewport(0, 0, (uint)width, (uint)height);
}
internal interface IFramebufferCameraTarget
{
void SetAspect(float aspect);
}
internal sealed class CameraFramebufferTarget(CameraController camera)
: IFramebufferCameraTarget
{
private readonly CameraController _camera = camera
?? throw new ArgumentNullException(nameof(camera));
public void SetAspect(float aspect) => _camera.SetAspect(aspect);
}
internal interface IFramebufferDevToolsTarget
{
void ResetLayout(int width, int height);
}
internal sealed class DevToolsFramebufferTarget(DevToolsFramePresenter presenter)
: IFramebufferDevToolsTarget
{
private readonly DevToolsFramePresenter _presenter = presenter
?? throw new ArgumentNullException(nameof(presenter));
public void ResetLayout(int width, int height) =>
_presenter.ResetLayout(
width,
height,
DevToolsPanelLayoutCondition.Always);
}
/// <summary>
/// The one framebuffer-size target. Logical gameplay/UI dimensions continue
/// to come from Window.Size; this owner only applies physical framebuffer
/// viewport/aspect changes in the frozen acdream host order.
/// </summary>
internal sealed class FramebufferResizeController
{
private readonly ViewportAspectState _viewportAspect;
private IFramebufferViewportTarget? _viewport;
private IFramebufferCameraTarget? _camera;
private IFramebufferDevToolsTarget? _devTools;
public FramebufferResizeController(ViewportAspectState viewportAspect) =>
_viewportAspect = viewportAspect
?? throw new ArgumentNullException(nameof(viewportAspect));
public void BindViewport(IFramebufferViewportTarget viewport)
{
ArgumentNullException.ThrowIfNull(viewport);
BindOnce(ref _viewport, viewport, "viewport");
}
public void BindCamera(IFramebufferCameraTarget camera)
{
ArgumentNullException.ThrowIfNull(camera);
BindOnce(ref _camera, camera, "camera");
}
public void BindDevTools(IFramebufferDevToolsTarget devTools)
{
ArgumentNullException.ThrowIfNull(devTools);
BindOnce(ref _devTools, devTools, "developer tools");
}
public void UnbindDevTools(IFramebufferDevToolsTarget devTools)
{
ArgumentNullException.ThrowIfNull(devTools);
if (ReferenceEquals(_devTools, devTools))
_devTools = null;
}
public void Resize(Vector2D<int> newSize) => Resize(newSize.X, newSize.Y);
public void Resize(int width, int height)
{
if (width <= 0 || height <= 0)
return;
// Frozen order: GL viewport, shared aspect publication, camera, then
// optional ImGui layout reset. Late binding never replays an earlier
// resize transition.
_viewport?.ResizeViewport(width, height);
_viewportAspect.Update(width, height);
_camera?.SetAspect(width / (float)height);
_devTools?.ResetLayout(width, height);
}
private static void BindOnce<T>(ref T? slot, T value, string name)
where T : class
{
if (slot is not null && !ReferenceEquals(slot, value))
throw new InvalidOperationException(
$"The framebuffer {name} target is already bound.");
slot = value;
}
}

View file

@ -27,6 +27,7 @@ public sealed class GameWindow : IDisposable
private SilkWindowCallbackBinding? _windowCallbacks; private SilkWindowCallbackBinding? _windowCallbacks;
private GL? _gl; private GL? _gl;
private IInputContext? _input; private IInputContext? _input;
private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext;
private TerrainModernRenderer? _terrain; private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by /// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary> /// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
@ -34,16 +35,7 @@ public sealed class GameWindow : IDisposable
private CameraController? _cameraController; private CameraController? _cameraController;
private IDatReaderWriter? _dats; private IDatReaderWriter? _dats;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private float _lastMouseX private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput;
{
get => _pointerPosition.X;
set => _pointerPosition.X = value;
}
private float _lastMouseY
{
get => _pointerPosition.Y;
set => _pointerPosition.Y = value;
}
private Shader? _meshShader; private Shader? _meshShader;
private TextureCache? _textureCache; private TextureCache? _textureCache;
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null /// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
@ -434,6 +426,8 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new(); private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new(); private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private readonly FramebufferResizeController _framebufferResize;
private IFramebufferDevToolsTarget? _framebufferDevToolsTarget;
private AcDream.App.Input.PlayerModeController? _playerModeController; private AcDream.App.Input.PlayerModeController? _playerModeController;
private readonly AcDream.App.Interaction.PlayerApproachCompletionState private readonly AcDream.App.Interaction.PlayerApproachCompletionState
_playerApproachCompletions = new(); _playerApproachCompletions = new();
@ -444,26 +438,6 @@ public sealed class GameWindow : IDisposable
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted // Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider; private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
// Mouse sensitivity multipliers — one per camera mode because the visual
// feel is very different. Adjust via F8 / F9 for whichever mode is
// currently active. Chase default is low because the character + camera
// rotating together is overwhelming at fly speeds.
private float _sensChase
{
get => _chaseCameraInput.Sensitivity;
set => _chaseCameraInput.Sensitivity = value;
}
private float _sensFly = 1.0f;
private float _sensOrbit = 1.0f;
// Right-mouse-button held → free-orbit the chase camera around the
// player without turning the character. Release leaves the camera at
// the orbited position (no snap back).
private bool _rmbHeld
{
get => _chaseCameraInput.RmbOrbitHeld;
}
// Phase K.2 — auto-enter player mode after a successful login. Armed // Phase K.2 — auto-enter player mode after a successful login. Armed
// by LiveSessionHost's entered-world transition; ticked from // by LiveSessionHost's entered-world transition; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any // OnUpdate; disarmed if the user manually enters fly mode (or any
@ -579,6 +553,7 @@ public sealed class GameWindow : IDisposable
_retainedInputCapture); _retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource( _movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_inputCapture); _inputCapture);
_framebufferResize = new FramebufferResizeController(_viewportAspect);
_datDir = options.DatDir; _datDir = options.DatDir;
_worldGameState = worldGameState; _worldGameState = worldGameState;
_worldEvents = worldEvents; _worldEvents = worldEvents;
@ -676,6 +651,8 @@ public sealed class GameWindow : IDisposable
_physicsEngine.DataCache = _physicsDataCache; _physicsEngine.DataCache = _physicsDataCache;
_gl = GL.GetApi(_window!); _gl = GL.GetApi(_window!);
_framebufferResize.BindViewport(
new SilkFramebufferViewportTarget(_gl));
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
@ -692,113 +669,36 @@ public sealed class GameWindow : IDisposable
// per K.1b plan §D). // per K.1b plan §D).
var firstKb = _input.Keyboards.FirstOrDefault(); var firstKb = _input.Keyboards.FirstOrDefault();
var firstMouse = _input.Mice.FirstOrDefault(); var firstMouse = _input.Mice.FirstOrDefault();
if (firstKb is not null && firstMouse is not null) if (firstKb is not null)
{ {
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(
_mouseSource = new AcDream.App.Input.SilkMouseSource( firstKb,
_hostQuiescence);
_kbSource.Attach();
}
if (firstMouse is not null)
{
_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(
firstMouse, firstMouse,
_inputCapture, _inputCapture,
_kbSource); _kbSource,
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _hostQuiescence);
_mouseSource.Attach();
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
}
if (_kbSource is not null && _mouseSource is not null)
{
_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(
_kbSource, _mouseSource, _keyBindings); _kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Attach();
_movementInput.Bind(_inputDispatcher); _movementInput.Bind(_inputDispatcher);
_cameraInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher);
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
_inputDispatcher.Fired += OnInputAction; _inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope; Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode); SetInputCombatScope(Combat.CurrentMode);
} }
// Mouse delta handler — kept direct because Silk.NET delivers mouse
// moves as continuous (x, y) positions, not chord events. We gate
// on WantCaptureMouse so panel hover never drives camera. The
// previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse
// never drives character yaw in K.1b. RMB-held orbit is wired via
// the dispatcher's AcdreamRmbOrbitHold action (subscriber sets
// _rmbHeld below).
foreach (var mouse in _input.Mice)
{
mouse.MouseMove += (m, pos) =>
{
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
// surviving direct-mouse handler. Suppresses RMB orbit /
// FlyCamera look while ImGui has the mouse focus.
if (_inputCapture.WantCaptureMouse)
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
return;
}
if (_cameraController is null) return;
float dx = pos.X - _lastMouseX;
float dy = pos.Y - _lastMouseY;
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
float sens = _sensChase;
if (_gameplayInputFrame?.MouseLookActive == true)
{
// DirectInput may deliver several device records before
// one retail GetInput pass. Accumulate raw motion here;
// the update loop filters the aggregate exactly once.
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
}
else if (_rmbHeld)
{
// Hold-RMB orbit: player stays the central point, camera
// free-orbits around. X rotates around, Y pitches. On release
// the camera STAYS at the new angle (no snap back).
// K.1b: this is the ONLY mouse-delta path that affects
// ANYTHING when in player mode — character yaw is
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
// above takes precedence when active.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
float nowSec = (float)(Environment.TickCount64 / 1000.0);
var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
_retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
}
else
{
_chaseCamera.YawOffset -= dx * 0.004f * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
}
// K-fix1 (2026-04-26): no default-pitch path. With
// neither MMB nor RMB held, mouse moves the cursor
// freely so the user can interact with panels +
// (eventually) click selectables in the world. Pitch
// is gated on a deliberate hold input.
}
else if (_cameraController.IsFlyMode)
{
float sens = _sensFly;
_cameraController.Fly.Look(dx * sens, dy * sens);
}
else
{
// Orbit-camera mode (offline / pre-login): hold LMB to
// drag-rotate. K.1b reads the mouse-button state via
// IMouseSource so all "is button held" queries go
// through the same abstraction the dispatcher uses.
float sens = _sensOrbit;
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
{
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
_cameraController.Orbit.Pitch = Math.Clamp(
_cameraController.Orbit.Pitch + dy * 0.005f * sens,
0.1f, 1.5f);
}
}
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
};
}
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.DepthTest);
@ -849,10 +749,26 @@ public sealed class GameWindow : IDisposable
Console.WriteLine("world-hud font: no system monospace font found"); Console.WriteLine("world-hud font: no system monospace font found");
} }
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; var orbit = new OrbitCamera();
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y }; var fly = new FlyCamera();
_cameraController = new CameraController(orbit, fly); _cameraController = new CameraController(orbit, fly);
_cameraController.ModeChanged += OnCameraModeChanged; _framebufferResize.BindCamera(
new CameraFramebufferTarget(_cameraController));
_framebufferResize.Resize(_window!.FramebufferSize);
if (_mouseSource is not null && firstMouse is not null)
{
_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(
_input.Mice,
_hostQuiescence,
_inputCapture,
_localPlayerMode,
_cameraController,
_chaseCameraInput,
_mouseSource,
_pointerPosition,
new AcDream.App.Input.EnvironmentInputMonotonicClock());
_cameraPointerInput.AttachRaw();
}
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir); _dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats); _magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
@ -959,8 +875,15 @@ public sealed class GameWindow : IDisposable
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
try try
{ {
_devToolsInputContext = new AcDream.App.Input.QuiescentInputContext(
_input!,
_hostQuiescence);
imguiBootstrap = imguiBootstrap =
new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); new AcDream.UI.ImGui.ImGuiBootstrapper(
_gl!,
_window!,
_devToolsInputContext);
_devToolsInputContext.Activate();
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// VitalsVM: GUID=0 at construction; set later at EnterWorld // VitalsVM: GUID=0 at construction; set later at EnterWorld
@ -1021,9 +944,11 @@ public sealed class GameWindow : IDisposable
_worldSceneDebugState.CollisionWireframesVisible, _worldSceneDebugState.CollisionWireframesVisible,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () => GetActiveSensitivity(), getMouseSensitivity: () =>
_cameraPointerInput?.ActiveSensitivity ?? 1f,
getChaseDistance: () => _chaseCamera?.Distance ?? 0f, getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () => _rmbHeld, getRmbOrbit: () =>
_cameraPointerInput?.RmbOrbitHeld ?? false,
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
getDayFraction: () => (float)WorldTime.DayFraction, getDayFraction: () => (float)WorldTime.DayFraction,
getWeather: () => Weather.Kind.ToString(), getWeather: () => Weather.Kind.ToString(),
@ -1229,6 +1154,9 @@ public sealed class GameWindow : IDisposable
debugPanel, debugPanel,
_debugVm, _debugVm,
settingsPanel)); settingsPanel));
_framebufferDevToolsTarget = new DevToolsFramebufferTarget(
_devToolsFramePresenter);
_framebufferResize.BindDevTools(_framebufferDevToolsTarget);
_devToolsFramePresenter.ResetLayout( _devToolsFramePresenter.ResetLayout(
_window!.Size.X, _window!.Size.X,
_window.Size.Y, _window.Size.Y,
@ -1241,6 +1169,13 @@ public sealed class GameWindow : IDisposable
// operation. Construction failure still owns this local and // operation. Construction failure still owns this local and
// must release it before abandoning the optional frontend. // must release it before abandoning the optional frontend.
imguiBootstrap?.Dispose(); imguiBootstrap?.Dispose();
if (_framebufferDevToolsTarget is { } framebufferTarget)
{
_framebufferResize.UnbindDevTools(framebufferTarget);
_framebufferDevToolsTarget = null;
}
_devToolsInputContext?.Dispose();
_devToolsInputContext = null;
_devToolsBackend = null; _devToolsBackend = null;
_devToolsFramePresenter = null; _devToolsFramePresenter = null;
_devToolsCameraMenu = null; _devToolsCameraMenu = null;
@ -1429,7 +1364,11 @@ public sealed class GameWindow : IDisposable
if (_options.RetailUi) if (_options.RetailUi)
{ {
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); _uiHost = new AcDream.App.UI.UiHost(
_gl,
shadersDir,
_debugFont,
_hostQuiescence);
_retainedInputCapture.Root = _uiHost.Root; _retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _persistedGameplay.LockUI; _uiHost.Root.UiLocked = _persistedGameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
@ -2031,7 +1970,9 @@ public sealed class GameWindow : IDisposable
camera.Projection, camera.Projection,
camera.Viewport); camera.Viewport);
}, },
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY), () => new System.Numerics.Vector2(
_pointerPosition.X,
_pointerPosition.Y),
() => _playerController is { } player () => _playerController is { } player
? new AcDream.App.Interaction.PlayerInteractionPose( ? new AcDream.App.Interaction.PlayerInteractionPose(
player.CellId, player.CellId,
@ -2472,6 +2413,7 @@ public sealed class GameWindow : IDisposable
mouseLookController, mouseLookController,
new AcDream.App.Input.CombatAttackInputFrameAdapter( new AcDream.App.Input.CombatAttackInputFrameAdapter(
_combatAttackController!)); _combatAttackController!));
_cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame);
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController( var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
_options.LiveMode, _options.LiveMode,
_localPlayerMode, _localPlayerMode,
@ -2559,7 +2501,6 @@ public sealed class GameWindow : IDisposable
_animatedEntities, _animatedEntities,
_equippedChildRenderer!, _equippedChildRenderer!,
liveEffectFrame); liveEffectFrame);
_viewportAspect.Update(_window!.Size.X, _window.Size.Y);
_playerModeController = new AcDream.App.Input.PlayerModeController( _playerModeController = new AcDream.App.Input.PlayerModeController(
_localPlayerMode, _localPlayerMode,
_playerControllerSlot, _playerControllerSlot,
@ -3169,31 +3110,6 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Update.UpdateFrameInput(dt)); new AcDream.App.Update.UpdateFrameInput(dt));
} }
private void OnCameraModeChanged(bool _modeBool)
{
if (_gameplayInputFrame?.MouseLookActive == true
&& _cameraController?.IsChaseMode != true)
{
_gameplayInputFrame?.EndMouseLook();
}
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// K-fix2 (2026-04-26): the bool passed to ModeChanged is NOT
// reliably "isFlyMode" — CameraController.EnterChaseMode invokes
// it with IsChaseMode (true), CameraController.ToggleFly invokes
// it with IsFlyMode, and CameraController.ExitChaseMode invokes
// it with IsFlyMode. Reading the controller state directly is
// the only correct gate. Cursor visible by default in chase /
// orbit modes; Raw cursor only in fly mode (continuous
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
// held is handled separately by the gameplay input owner.
bool needsRawCursor = _cameraController?.IsFlyMode == true;
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
}
// Performance overlay state — updated every ~0.5s and written to the // Performance overlay state — updated every ~0.5s and written to the
// window title so there's zero rendering cost (no font/overlay needed). // window title so there's zero rendering cost (no font/overlay needed).
private void OnRender(double deltaSeconds) private void OnRender(double deltaSeconds)
@ -3325,13 +3241,6 @@ 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;
private float GetActiveSensitivity()
{
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
if (_cameraController?.IsFlyMode == true) return _sensFly;
return _sensOrbit;
}
/// <summary> /// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7 /// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel /// keybind handler; called by both the keybind AND the DebugPanel
@ -3611,21 +3520,7 @@ public sealed class GameWindow : IDisposable
/// previously off the new viewport snap back to default positions. /// previously off the new viewport snap back to default positions.
/// </summary> /// </summary>
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize) private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
{ => _framebufferResize.Resize(newSize);
if (newSize.X <= 0 || newSize.Y <= 0) return;
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
_viewportAspect.Update(newSize.X, newSize.Y);
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
// Resize is always a force-reset — the alternative ("clamp
// existing positions") would require tracking each panel's
// current pos+size, which ImGuiNET doesn't expose by name.
// Force-reset is acceptable UX because resizing happens rarely
// and the user can always drag panels back where they want.
_devToolsFramePresenter?.ResetLayout(
newSize.X,
newSize.Y,
AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always);
}
/// <summary> /// <summary>
/// L.0 Display tab: apply the window-state-dependent settings /// L.0 Display tab: apply the window-state-dependent settings
@ -3701,11 +3596,8 @@ public sealed class GameWindow : IDisposable
// Diagnostic — kept from K.1a; helpful for K.1c verification. // Diagnostic — kept from K.1a; helpful for K.1c verification.
Console.WriteLine($"[input] {action} {activation}"); Console.WriteLine($"[input] {action} {activation}");
// RMB-orbit hold: track press/release transitions explicitly so // RMB orbit and instant mouse-look remain the first accepted input
// _rmbHeld is true exactly while the chord is held. Hold-type // transition. Their state is owned by the gameplay/pointer owners.
// chords also fire Press on key-down + Release on key-up; we
// ignore the in-between Hold ticks here (the mouse-move handler
// checks _rmbHeld each frame anyway).
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
return; return;
@ -3958,20 +3850,10 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private void AdjustActiveSensitivity(float factor) private void AdjustActiveSensitivity(float factor)
{ {
string modeLabel; if (_cameraPointerInput is not { } pointer)
float current; return;
if (_playerMode && _cameraController?.IsChaseMode == true) string message = pointer.AdjustSensitivity(factor);
{ modeLabel = "Chase"; current = _sensChase; } _debugVm?.AddToast(message);
else if (_cameraController?.IsFlyMode == true)
{ modeLabel = "Fly"; current = _sensFly; }
else
{ modeLabel = "Orbit"; current = _sensOrbit; }
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
if (modeLabel == "Chase") _sensChase = next;
else if (modeLabel == "Fly") _sensFly = next;
else _sensOrbit = next;
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
} }
/// <summary> /// <summary>
@ -4040,28 +3922,7 @@ public sealed class GameWindow : IDisposable
/// retail wheel feel). /// retail wheel feel).
/// </summary> /// </summary>
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
{ => _cameraPointerInput?.HandleScroll(action);
if (_cameraController is null) return;
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
if (_playerMode && _cameraController.IsChaseMode)
{
// Chase mode: zoom (closer on ScrollUp).
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
_retailChaseCamera.AdjustDistance(-dir * 0.8f);
else if (_chaseCamera is not null)
_chaseCamera.AdjustDistance(-dir * 0.8f);
}
else if (_cameraController.IsFlyMode)
{
// Fly mode: no-op (could adjust move speed later).
}
else
{
_cameraController.Orbit.Distance = Math.Clamp(
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
}
}
private void OnClosing() private void OnClosing()
{ {
@ -4088,6 +3949,20 @@ public sealed class GameWindow : IDisposable
} }
private ResourceShutdownTransaction CreateShutdownTransaction() => new( private ResourceShutdownTransaction CreateShutdownTransaction() => new(
// Logical cutoff precedes the live session's potentially long graceful
// close. Physical event removal follows session retirement, so a bad
// Silk accessor cannot prevent F653/transport teardown. The input
// context and UI owners remain alive for reset, while copied callbacks
// are already inert.
new ResourceShutdownStage("input callback deactivation",
[
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
new("dispatcher", () => _inputDispatcher?.Deactivate()),
new("mouse source", () => _mouseSource?.Deactivate()),
new("keyboard source", () => _kbSource?.Deactivate()),
new("retained UI input", () => _uiHost?.QuiesceInput()),
new("developer tools input", () => _devToolsInputContext?.Deactivate()),
]),
// Live-session reset retires the complete streaming window. Every // Live-session reset retires the complete streaming window. Every
// reset callback owner, especially LandblockStreamer, must remain live // reset callback owner, especially LandblockStreamer, must remain live
// until that transaction converges. // until that transaction converges.
@ -4114,6 +3989,57 @@ public sealed class GameWindow : IDisposable
} }
}), }),
]), ]),
new ResourceShutdownStage("input callback detach",
[
new("retained UI input", () => _uiHost?.DeactivateInput()),
new("developer tools input", () => _devToolsInputContext?.Dispose()),
new("camera pointer", () =>
{
AcDream.App.Input.CameraPointerInputController? pointer =
_cameraPointerInput;
if (pointer is null) return;
pointer.Dispose();
if (!pointer.IsDisposalComplete)
throw new InvalidOperationException(
"Camera pointer callback removal remains pending.");
}),
new("combat input subscription", () =>
Combat.CombatModeChanged -= SetInputCombatScope),
new("dispatcher", () =>
{
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
_inputDispatcher;
if (dispatcher is null) return;
dispatcher.Fired -= OnInputAction;
_movementInput.Unbind(dispatcher);
_cameraInput.Unbind(dispatcher);
dispatcher.Dispose();
if (!dispatcher.IsDisposalComplete)
throw new InvalidOperationException(
"Input dispatcher source removal remains pending.");
_inputDispatcher = null;
}),
new("mouse source", () =>
{
AcDream.App.Input.SilkMouseSource? source = _mouseSource;
if (source is null) return;
source.Dispose();
if (!source.IsDisposalComplete)
throw new InvalidOperationException(
"Mouse source callback removal remains pending.");
_mouseSource = null;
}),
new("keyboard source", () =>
{
AcDream.App.Input.SilkKeyboardSource? source = _kbSource;
if (source is null) return;
source.Dispose();
if (!source.IsDisposalComplete)
throw new InvalidOperationException(
"Keyboard source callback removal remains pending.");
_kbSource = null;
}),
]),
// Frame composition borrows equipped, effect, audio, and render // Frame composition borrows equipped, effect, audio, and render
// owners. Withdraw the graph before the first borrowed owner closes; // owners. Withdraw the graph before the first borrowed owner closes;
// the later render-frontend stage still disposes the GL owners. // the later render-frontend stage still disposes the GL owners.
@ -4126,7 +4052,16 @@ public sealed class GameWindow : IDisposable
]), ]),
new ResourceShutdownStage("session dependents", new ResourceShutdownStage("session dependents",
[ [
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()), new("mouse capture", () =>
{
AcDream.App.Input.CameraPointerInputController? pointer =
_cameraPointerInput;
if (pointer is null) return;
pointer.ReleaseMouseLookAfterSessionRetirement();
if (_gameplayInputFrame is { } gameplayFrame)
pointer.UnbindGameplayFrame(gameplayFrame);
_cameraPointerInput = null;
}),
new("retail UI", () => new("retail UI", () =>
{ {
_retailUiRuntime?.Dispose(); _retailUiRuntime?.Dispose();
@ -4196,11 +4131,17 @@ public sealed class GameWindow : IDisposable
[ [
new("developer tools", () => new("developer tools", () =>
{ {
if (_framebufferDevToolsTarget is { } framebufferTarget)
{
_framebufferResize.UnbindDevTools(framebufferTarget);
_framebufferDevToolsTarget = null;
}
_devToolsFramePresenter = null; _devToolsFramePresenter = null;
_devToolsCameraMenu = null; _devToolsCameraMenu = null;
_devToolsCommandBus = null; _devToolsCommandBus = null;
_devToolsBackend?.Dispose(); _devToolsBackend?.Dispose();
_devToolsBackend = null; _devToolsBackend = null;
_devToolsInputContext = null;
}), }),
new("portal tunnel", () => new("portal tunnel", () =>
{ {
@ -4281,8 +4222,6 @@ public sealed class GameWindow : IDisposable
if (ReferenceEquals(_windowCallbacks, binding)) if (ReferenceEquals(_windowCallbacks, binding))
_windowCallbacks = null; _windowCallbacks = null;
}), }),
new("combat input subscription", ()
=> Combat.CombatModeChanged -= SetInputCombatScope),
new("input context", () => new("input context", () =>
{ {
_input?.Dispose(); _input?.Dispose();
@ -4299,10 +4238,7 @@ public sealed class GameWindow : IDisposable
])); ]));
private void OnFocusChanged(bool focused) private void OnFocusChanged(bool focused)
{ => _cameraPointerInput?.HandleFocusChanged(focused);
if (!focused)
_gameplayInputFrame?.EndMouseLook();
}
public void Dispose() public void Dispose()
{ {

View file

@ -0,0 +1,420 @@
using System.Numerics;
using AcDream.App.Rendering;
using Silk.NET.Input;
namespace AcDream.App.UI;
internal interface IRetainedUiInputBinding : IDisposable
{
bool IsDisposalComplete { get; }
void Attach();
void Deactivate();
}
internal interface IRetainedMouseSurface
{
void AddMouseDown(Action<MouseButton, int, int> callback);
void RemoveMouseDown(Action<MouseButton, int, int> callback);
void AddMouseUp(Action<MouseButton, int, int> callback);
void RemoveMouseUp(Action<MouseButton, int, int> callback);
void AddMouseMove(Action<int, int> callback);
void RemoveMouseMove(Action<int, int> callback);
void AddScroll(Action<int> callback);
void RemoveScroll(Action<int> callback);
}
internal sealed class SilkRetainedMouseSurface : IRetainedMouseSurface
{
private readonly IMouse _mouse;
private Action<MouseButton, int, int>? _downCallback;
private Action<MouseButton, int, int>? _upCallback;
private Action<int, int>? _moveCallback;
private Action<int>? _scrollCallback;
private readonly Action<IMouse, MouseButton> _down;
private readonly Action<IMouse, MouseButton> _up;
private readonly Action<IMouse, Vector2> _move;
private readonly Action<IMouse, ScrollWheel> _scroll;
public SilkRetainedMouseSurface(IMouse mouse)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_down = OnDown;
_up = OnUp;
_move = OnMove;
_scroll = OnScroll;
}
public void AddMouseDown(Action<MouseButton, int, int> callback)
{
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseDown += _down;
}
public void RemoveMouseDown(Action<MouseButton, int, int> callback)
{
if (!ReferenceEquals(_downCallback, callback)) return;
_mouse.MouseDown -= _down;
_downCallback = null;
}
public void AddMouseUp(Action<MouseButton, int, int> callback)
{
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseUp += _up;
}
public void RemoveMouseUp(Action<MouseButton, int, int> callback)
{
if (!ReferenceEquals(_upCallback, callback)) return;
_mouse.MouseUp -= _up;
_upCallback = null;
}
public void AddMouseMove(Action<int, int> callback)
{
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.MouseMove += _move;
}
public void RemoveMouseMove(Action<int, int> callback)
{
if (!ReferenceEquals(_moveCallback, callback)) return;
_mouse.MouseMove -= _move;
_moveCallback = null;
}
public void AddScroll(Action<int> callback)
{
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_mouse.Scroll += _scroll;
}
public void RemoveScroll(Action<int> callback)
{
if (!ReferenceEquals(_scrollCallback, callback)) return;
_mouse.Scroll -= _scroll;
_scrollCallback = null;
}
private void OnDown(IMouse sender, MouseButton button) =>
_downCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
private void OnUp(IMouse sender, MouseButton button) =>
_upCallback?.Invoke(button, (int)sender.Position.X, (int)sender.Position.Y);
private void OnMove(IMouse _, Vector2 position) =>
_moveCallback?.Invoke((int)position.X, (int)position.Y);
private void OnScroll(IMouse _, ScrollWheel scroll) =>
_scrollCallback?.Invoke((int)scroll.Y);
}
internal sealed class RetainedMouseInputBinding : IRetainedUiInputBinding
{
private readonly IRetainedMouseSurface _surface;
private readonly UiRoot _root;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<MouseButton, int, int> _down;
private readonly Action<MouseButton, int, int> _up;
private readonly Action<int, int> _move;
private readonly Action<int> _scroll;
private readonly bool[] _attached = new bool[4];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public RetainedMouseInputBinding(
IRetainedMouseSurface surface,
UiRoot root,
HostQuiescenceGate quiescence)
{
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
_root = root ?? throw new ArgumentNullException(nameof(root));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_down = OnDown;
_up = OnUp;
_move = OnMove;
_scroll = OnScroll;
}
public bool IsDisposalComplete => _attached.All(static value => !value);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException("Retained mouse attachment has already started.");
_attachStarted = true;
try
{
_attached[0] = true;
_surface.AddMouseDown(_down);
_attached[1] = true;
_surface.AddMouseUp(_up);
_attached[2] = true;
_surface.AddMouseMove(_move);
_attached[3] = true;
_surface.AddScroll(_scroll);
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
RollBackOrThrow(attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnDown(MouseButton button, int x, int y) =>
Invoke(() => _root.OnMouseDown(MapButton(button), x, y));
private void OnUp(MouseButton button, int x, int y) =>
Invoke(() => _root.OnMouseUp(MapButton(button), x, y));
private void OnMove(int x, int y) => Invoke(() => _root.OnMouseMove(x, y));
private void OnScroll(int amount) => Invoke(() => _root.OnScroll(amount));
private void Invoke(Action callback) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
callback();
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("retained mouse callbacks",
[
new("scroll", () => Remove(3)),
new("move", () => Remove(2)),
new("up", () => Remove(1)),
new("down", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index]) return;
switch (index)
{
case 0: _surface.RemoveMouseDown(_down); break;
case 1: _surface.RemoveMouseUp(_up); break;
case 2: _surface.RemoveMouseMove(_move); break;
case 3: _surface.RemoveScroll(_scroll); break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
private void RollBackOrThrow(Exception attachError)
{
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Retained mouse registration and rollback both failed.",
new InvalidOperationException("Retained mouse registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Retained mouse registration failed and was rolled back.", attachError);
}
private static UiMouseButton MapButton(MouseButton button) => button switch
{
MouseButton.Left => UiMouseButton.Left,
MouseButton.Right => UiMouseButton.Right,
MouseButton.Middle => UiMouseButton.Middle,
_ => UiMouseButton.Left,
};
}
internal interface IRetainedKeyboardSurface
{
void AddKeyDown(Action<Key> callback);
void RemoveKeyDown(Action<Key> callback);
void AddKeyUp(Action<Key> callback);
void RemoveKeyUp(Action<Key> callback);
void AddKeyChar(Action<char> callback);
void RemoveKeyChar(Action<char> callback);
}
internal sealed class SilkRetainedKeyboardSurface : IRetainedKeyboardSurface
{
private readonly IKeyboard _keyboard;
private Action<Key>? _downCallback;
private Action<Key>? _upCallback;
private Action<char>? _charCallback;
private readonly Action<IKeyboard, Key, int> _down;
private readonly Action<IKeyboard, Key, int> _up;
private readonly Action<IKeyboard, char> _char;
public SilkRetainedKeyboardSurface(IKeyboard keyboard)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_down = OnDown;
_up = OnUp;
_char = OnChar;
}
public void AddKeyDown(Action<Key> callback)
{
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_keyboard.KeyDown += _down;
}
public void RemoveKeyDown(Action<Key> callback)
{
if (!ReferenceEquals(_downCallback, callback)) return;
_keyboard.KeyDown -= _down;
_downCallback = null;
}
public void AddKeyUp(Action<Key> callback)
{
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_keyboard.KeyUp += _up;
}
public void RemoveKeyUp(Action<Key> callback)
{
if (!ReferenceEquals(_upCallback, callback)) return;
_keyboard.KeyUp -= _up;
_upCallback = null;
}
public void AddKeyChar(Action<char> callback)
{
_charCallback = callback ?? throw new ArgumentNullException(nameof(callback));
_keyboard.KeyChar += _char;
}
public void RemoveKeyChar(Action<char> callback)
{
if (!ReferenceEquals(_charCallback, callback)) return;
_keyboard.KeyChar -= _char;
_charCallback = null;
}
private void OnDown(IKeyboard _, Key key, int __) => _downCallback?.Invoke(key);
private void OnUp(IKeyboard _, Key key, int __) => _upCallback?.Invoke(key);
private void OnChar(IKeyboard _, char value) => _charCallback?.Invoke(value);
}
internal sealed class RetainedKeyboardInputBinding : IRetainedUiInputBinding
{
private readonly IRetainedKeyboardSurface _surface;
private readonly UiRoot _root;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<Key> _down;
private readonly Action<Key> _up;
private readonly Action<char> _char;
private readonly bool[] _attached = new bool[3];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public RetainedKeyboardInputBinding(
IRetainedKeyboardSurface surface,
UiRoot root,
HostQuiescenceGate quiescence)
{
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
_root = root ?? throw new ArgumentNullException(nameof(root));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_down = key => Invoke(() => _root.OnKeyDown((int)key));
_up = key => Invoke(() => _root.OnKeyUp((int)key));
_char = value => Invoke(() => _root.OnChar(value));
}
public bool IsDisposalComplete => _attached.All(static value => !value);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException("Retained keyboard attachment has already started.");
_attachStarted = true;
try
{
_attached[0] = true;
_surface.AddKeyDown(_down);
_attached[1] = true;
_surface.AddKeyUp(_up);
_attached[2] = true;
_surface.AddKeyChar(_char);
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
RollBackOrThrow(attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void Invoke(Action callback) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
callback();
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("retained keyboard callbacks",
[
new("character", () => Remove(2)),
new("up", () => Remove(1)),
new("down", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index]) return;
switch (index)
{
case 0: _surface.RemoveKeyDown(_down); break;
case 1: _surface.RemoveKeyUp(_up); break;
case 2: _surface.RemoveKeyChar(_char); break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
private void RollBackOrThrow(Exception attachError)
{
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Retained keyboard registration and rollback both failed.",
new InvalidOperationException("Retained keyboard registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Retained keyboard registration failed and was rolled back.", attachError);
}
}

View file

@ -49,15 +49,27 @@ public sealed class UiHost : System.IDisposable
public IKeyboard? Keyboard { get; private set; } public IKeyboard? Keyboard { get; private set; }
private long _startTicks = System.Environment.TickCount64; private long _startTicks = System.Environment.TickCount64;
private readonly List<System.Action> _inputUnsubscribers = new(); private readonly HostQuiescenceGate _quiescence;
private readonly List<IRetainedUiInputBinding> _inputBindings = new();
private ResourceShutdownTransaction? _inputShutdown;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private bool _disposeRequested; private bool _disposeRequested;
private bool _disposed; private bool _disposed;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
: this(gl, shaderDir, defaultFont, new HostQuiescenceGate())
{
}
internal UiHost(
GL gl,
string shaderDir,
BitmapFont? defaultFont,
HostQuiescenceGate quiescence)
{ {
TextRenderer = new TextRenderer(gl, shaderDir); TextRenderer = new TextRenderer(gl, shaderDir);
DefaultFont = defaultFont; DefaultFont = defaultFont;
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
} }
// ── Per-frame ────────────────────────────────────────────────────── // ── Per-frame ──────────────────────────────────────────────────────
@ -86,25 +98,21 @@ public sealed class UiHost : System.IDisposable
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
System.ArgumentNullException.ThrowIfNull(mouse); System.ArgumentNullException.ThrowIfNull(mouse);
void OnMouseDown(IMouse sender, MouseButton button) => var binding = new RetainedMouseInputBinding(
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); new SilkRetainedMouseSurface(mouse),
void OnMouseUp(IMouse sender, MouseButton button) => Root,
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); _quiescence);
void OnMouseMove(IMouse sender, Vector2 position) => _inputBindings.Add(binding);
Root.OnMouseMove((int)position.X, (int)position.Y); try
void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y);
mouse.MouseDown += OnMouseDown;
mouse.MouseUp += OnMouseUp;
mouse.MouseMove += OnMouseMove;
mouse.Scroll += OnScroll;
_inputUnsubscribers.Add(() =>
{ {
mouse.MouseDown -= OnMouseDown; binding.Attach();
mouse.MouseUp -= OnMouseUp; }
mouse.MouseMove -= OnMouseMove; catch
mouse.Scroll -= OnScroll; {
}); if (binding.IsDisposalComplete)
_inputBindings.Remove(binding);
throw;
}
} }
public void WireKeyboard(IKeyboard kb) public void WireKeyboard(IKeyboard kb)
@ -112,28 +120,54 @@ public sealed class UiHost : System.IDisposable
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
System.ArgumentNullException.ThrowIfNull(kb); System.ArgumentNullException.ThrowIfNull(kb);
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key); var binding = new RetainedKeyboardInputBinding(
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key); new SilkRetainedKeyboardSurface(kb),
void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value); Root,
_quiescence);
kb.KeyDown += OnKeyDown; _inputBindings.Add(binding);
kb.KeyUp += OnKeyUp; try
kb.KeyChar += OnKeyChar;
_inputUnsubscribers.Add(() =>
{ {
kb.KeyDown -= OnKeyDown; binding.Attach();
kb.KeyUp -= OnKeyUp; }
kb.KeyChar -= OnKeyChar; catch
}); {
if (binding.IsDisposalComplete)
_inputBindings.Remove(binding);
throw;
}
} }
private static UiMouseButton MapButton(MouseButton b) => b switch /// <summary>
/// Stops retained-device delivery without retiring the window tree or GL
/// renderer. Safe to call before a potentially long live-session close.
/// </summary>
public void QuiesceInput()
{ {
MouseButton.Left => UiMouseButton.Left, _disposeRequested = true;
MouseButton.Right => UiMouseButton.Right, foreach (IRetainedUiInputBinding binding in _inputBindings)
MouseButton.Middle => UiMouseButton.Middle, binding.Deactivate();
_ => UiMouseButton.Left, Keyboard = null;
}; }
/// <summary>Physically removes every retained input edge after quiescence.</summary>
public void DeactivateInput()
{
QuiesceInput();
_inputShutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage(
"retained UI input subscriptions",
_inputBindings
.AsEnumerable()
.Reverse()
.Select((binding, index) => new ResourceShutdownOperation(
$"input binding {index}",
binding.Dispose))
.ToArray()));
_inputShutdown.CompleteOrThrow();
if (_inputShutdown.IsComplete)
_inputBindings.Clear();
}
// ── Window manager forwarders (delegate to UiRoot) ───────────────── // ── Window manager forwarders (delegate to UiRoot) ─────────────────
@ -169,12 +203,8 @@ public sealed class UiHost : System.IDisposable
_disposeRequested = true; _disposeRequested = true;
_shutdown ??= CreateShutdownTransaction( _shutdown ??= CreateShutdownTransaction(
_inputUnsubscribers.AsEnumerable().Reverse().ToArray(), [DeactivateInput],
() => () => { },
{
_inputUnsubscribers.Clear();
Keyboard = null;
},
WindowManager.Dispose, WindowManager.Dispose,
TextRenderer.Dispose); TextRenderer.Dispose);
_shutdown.CompleteOrThrow(); _shutdown.CompleteOrThrow();

View file

@ -30,7 +30,7 @@ namespace AcDream.UI.Abstractions.Input;
/// action stream. /// action stream.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class InputDispatcher public sealed class InputDispatcher : IDisposable
{ {
private readonly IKeyboardSource _keyboard; private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse; private readonly IMouseSource _mouse;
@ -39,6 +39,10 @@ public sealed class InputDispatcher
private InputScope? _combatScope; private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new(); private readonly HashSet<KeyChord> _heldHoldChords = new();
private readonly HashSet<InputAction> _automationHeldActions = new(); private readonly HashSet<InputAction> _automationHeldActions = new();
private readonly bool[] _sourceAttached = new bool[5];
private bool _attachStarted;
private int _disposeRequested;
private int _active;
// Double-click detection. _lastMouseDownButton == null means no recent press. // Double-click detection. _lastMouseDownButton == null means no recent press.
// _lastMouseDownTickMs is Environment.TickCount64 at the time of that press. // _lastMouseDownTickMs is Environment.TickCount64 at the time of that press.
@ -57,7 +61,10 @@ public sealed class InputDispatcher
/// Multicast — every subscriber gets every event in subscription order.</summary> /// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired; public event Action<InputAction, ActivationType>? Fired;
public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings) private InputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings)
{ {
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
@ -66,11 +73,84 @@ public sealed class InputDispatcher
_scopes.Push(InputScope.Always); // bottom of the stack _scopes.Push(InputScope.Always); // bottom of the stack
_scopes.Push(InputScope.Game); // default top for normal play _scopes.Push(InputScope.Game); // default top for normal play
_keyboard.KeyDown += OnKeyDown; }
_keyboard.KeyUp += OnKeyUp;
_mouse.MouseDown += OnMouseDown; /// <summary>
_mouse.MouseUp += OnMouseUp; /// Production two-phase construction seam. The host publishes the owner
_mouse.Scroll += OnScroll; /// before <see cref="Attach"/> so failed custom event accessors cannot
/// orphan a source subscription.
/// </summary>
public static InputDispatcher CreateDetached(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
new(keyboard, mouse, bindings);
public bool IsDisposalComplete =>
_sourceAttached.All(static attached => !attached);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
throw new InvalidOperationException(
"Input dispatcher attachment has already started.");
_attachStarted = true;
try
{
_sourceAttached[0] = true;
_keyboard.KeyDown += OnKeyDown;
_sourceAttached[1] = true;
_keyboard.KeyUp += OnKeyUp;
_sourceAttached[2] = true;
_mouse.MouseDown += OnMouseDown;
_sourceAttached[3] = true;
_mouse.MouseUp += OnMouseUp;
_sourceAttached[4] = true;
_mouse.Scroll += OnScroll;
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
List<Exception> rollbackErrors = DetachSources();
if (rollbackErrors.Count != 0)
{
rollbackErrors.Insert(0, new InvalidOperationException(
"Input dispatcher source registration failed.",
attachError));
throw new AggregateException(
"Input dispatcher registration and rollback both failed.",
rollbackErrors);
}
throw new InvalidOperationException(
"Input dispatcher registration failed and was rolled back.",
attachError);
}
}
/// <summary>Immediate logical cutoff; physical detach remains retriable.</summary>
public void Deactivate()
{
Interlocked.Exchange(ref _active, 0);
_captureCallback = null;
_heldHoldChords.Clear();
_automationHeldActions.Clear();
}
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
List<Exception> failures = DetachSources();
if (failures.Count != 0)
throw new AggregateException(
"One or more input dispatcher source callbacks could not be detached.",
failures);
} }
/// <summary> /// <summary>
@ -85,7 +165,9 @@ public sealed class InputDispatcher
{ {
if (action == InputAction.None || !Enum.IsDefined(action)) if (action == InputAction.None || !Enum.IsDefined(action))
throw new ArgumentOutOfRangeException(nameof(action)); throw new ArgumentOutOfRangeException(nameof(action));
if (_captureCallback is not null || _mouse.WantCaptureKeyboard) if (Volatile.Read(ref _active) == 0
|| _captureCallback is not null
|| _mouse.WantCaptureKeyboard)
return false; return false;
Fired?.Invoke(action, ActivationType.Press); Fired?.Invoke(action, ActivationType.Press);
@ -105,6 +187,9 @@ public sealed class InputDispatcher
if (action == InputAction.None || !Enum.IsDefined(action)) if (action == InputAction.None || !Enum.IsDefined(action))
throw new ArgumentOutOfRangeException(nameof(action)); throw new ArgumentOutOfRangeException(nameof(action));
if (Volatile.Read(ref _active) == 0)
return false;
if (held) if (held)
{ {
if (_captureCallback is not null || _mouse.WantCaptureKeyboard) if (_captureCallback is not null || _mouse.WantCaptureKeyboard)
@ -217,7 +302,7 @@ public sealed class InputDispatcher
/// </summary> /// </summary>
public bool IsActionHeld(InputAction action) public bool IsActionHeld(InputAction action)
{ {
if (action == InputAction.None) return false; if (Volatile.Read(ref _active) == 0 || action == InputAction.None) return false;
// While a text field owns the keyboard ("write mode"), held game actions read as // While a text field owns the keyboard ("write mode"), held game actions read as
// released: typing "swd" must not move the character. This is the polling-path twin // released: typing "swd" must not move the character. This is the polling-path twin
// of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys; // of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys;
@ -341,7 +426,7 @@ public sealed class InputDispatcher
/// </summary> /// </summary>
public void Tick() public void Tick()
{ {
if (_mouse.WantCaptureKeyboard) return; if (Volatile.Read(ref _active) == 0 || _mouse.WantCaptureKeyboard) return;
// Snapshot to avoid issues if a subscriber mutates _heldHoldChords. // Snapshot to avoid issues if a subscriber mutates _heldHoldChords.
if (_heldHoldChords.Count == 0) return; if (_heldHoldChords.Count == 0) return;
@ -363,6 +448,7 @@ public sealed class InputDispatcher
private void OnKeyDown(Key key, ModifierMask mods) private void OnKeyDown(Key key, ModifierMask mods)
{ {
if (Volatile.Read(ref _active) == 0) return;
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes // K.3 modal capture (used by Settings panel's "Rebind" UX) takes
// precedence over both WantCaptureKeyboard gating AND normal // precedence over both WantCaptureKeyboard gating AND normal
// binding lookup. Esc cancels capture; modifier-only keys don't // binding lookup. Esc cancels capture; modifier-only keys don't
@ -417,6 +503,7 @@ public sealed class InputDispatcher
private void OnKeyUp(Key key, ModifierMask mods) private void OnKeyUp(Key key, ModifierMask mods)
{ {
if (Volatile.Read(ref _active) == 0) return;
// Release fires regardless of WantCaptureKeyboard so we don't // Release fires regardless of WantCaptureKeyboard so we don't
// strand a Hold subscriber in the "held" state if the UI captured // strand a Hold subscriber in the "held" state if the UI captured
// mid-press. // mid-press.
@ -444,6 +531,7 @@ public sealed class InputDispatcher
private void OnMouseDown(MouseButton button, ModifierMask mods) private void OnMouseDown(MouseButton button, ModifierMask mods)
{ {
if (Volatile.Read(ref _active) == 0) return;
if (_mouse.WantCaptureMouse) return; if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
@ -478,6 +566,7 @@ public sealed class InputDispatcher
private void OnMouseUp(MouseButton button, ModifierMask mods) private void OnMouseUp(MouseButton button, ModifierMask mods)
{ {
if (Volatile.Read(ref _active) == 0) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = FindActive(chord, ActivationType.Release); var release = FindActive(chord, ActivationType.Release);
@ -500,6 +589,7 @@ public sealed class InputDispatcher
private void OnScroll(float delta) private void OnScroll(float delta)
{ {
if (Volatile.Read(ref _active) == 0) return;
if (_mouse.WantCaptureMouse) return; if (_mouse.WantCaptureMouse) return;
// K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the // K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the
// sign of the delta. Magnitude is dropped — the action is a // sign of the delta. Magnitude is dropped — the action is a
@ -526,4 +616,34 @@ public sealed class InputDispatcher
MouseButton.Button5 => (Key)(-1005), MouseButton.Button5 => (Key)(-1005),
_ => (Key)(-1000 - (int)button), _ => (Key)(-1000 - (int)button),
}; };
private List<Exception> DetachSources()
{
var failures = new List<Exception>();
for (int index = _sourceAttached.Length - 1; index >= 0; index--)
{
if (!_sourceAttached[index])
continue;
try
{
switch (index)
{
case 0: _keyboard.KeyDown -= OnKeyDown; break;
case 1: _keyboard.KeyUp -= OnKeyUp; break;
case 2: _mouse.MouseDown -= OnMouseDown; break;
case 3: _mouse.MouseUp -= OnMouseUp; break;
case 4: _mouse.Scroll -= OnScroll; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
_sourceAttached[index] = false;
}
catch (Exception error)
{
failures.Add(new InvalidOperationException(
$"Input dispatcher source callback {index} could not be detached.",
error));
}
}
return failures;
}
} }

View file

@ -0,0 +1,287 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Tests.Input;
public sealed class CameraPointerInputControllerTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void AttachFailureRollsBackRawPrefixAndLeavesCameraUnsubscribed(int failureIndex)
{
var surfaces = new[] { new RawSurface(), new RawSurface(), new RawSurface() };
surfaces[failureIndex].FailAdd = true;
CameraPointerInputController owner = Create(surfaces).Owner;
Assert.ThrowsAny<Exception>(owner.AttachRaw);
Assert.True(owner.IsDisposalComplete);
Assert.All(surfaces, surface => Assert.Equal(0, surface.LiveEdges));
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.AddCalls));
Assert.Equal(failureIndex + 1, surfaces.Sum(surface => surface.RemoveCalls));
}
[Fact]
public void FlyPointerUsesAbsoluteDeltaAndCaptureAdvancesPointerWithoutLooking()
{
var surface = new RawSurface();
var fixture = Create([surface]);
fixture.Camera.ToggleFly();
fixture.Owner.AttachRaw();
float originalYaw = fixture.Camera.Fly.Yaw;
float originalPitch = fixture.Camera.Fly.Pitch;
surface.Raise(new Vector2(10f, 5f));
Assert.Equal(originalYaw - 10f * 0.003f, fixture.Camera.Fly.Yaw, 5);
Assert.Equal(originalPitch - 5f * 0.003f, fixture.Camera.Fly.Pitch, 5);
fixture.Capture.Mouse = true;
surface.Raise(new Vector2(100f, 100f));
float capturedYaw = fixture.Camera.Fly.Yaw;
float capturedPitch = fixture.Camera.Fly.Pitch;
fixture.Capture.Mouse = false;
surface.Raise(new Vector2(102f, 97f));
Assert.Equal(capturedYaw - 2f * 0.003f, fixture.Camera.Fly.Yaw, 5);
Assert.Equal(capturedPitch + 3f * 0.003f, fixture.Camera.Fly.Pitch, 5);
}
[Fact]
public void RawMoveRaisedReentrantlyDuringAttachCannotEnterPartialOwner()
{
var surface = new RawSurface { RaiseDuringAdd = true };
var fixture = Create([surface]);
fixture.Camera.ToggleFly();
float yaw = fixture.Camera.Fly.Yaw;
fixture.Owner.AttachRaw();
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
surface.Raise(new Vector2(3, 0));
Assert.NotEqual(yaw, fixture.Camera.Fly.Yaw);
}
[Fact]
public void LateGameplayBindDoesNotResubscribeAndFocusLossEndsMouseLook()
{
var surface = new RawSurface();
var fixture = Create([surface]);
fixture.Owner.AttachRaw();
int adds = surface.AddCalls;
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
mouseLook,
new Combat());
fixture.Owner.BindGameplayFrame(frame);
fixture.Owner.HandleFocusChanged(focused: false);
Assert.Equal(adds, surface.AddCalls);
Assert.Equal(1, mouseLook.LifecycleEndCalls);
}
[Fact]
public void DeactivateSilencesCopiedRawAndCameraDelegatesBeforeDetach()
{
var surface = new RawSurface();
var fixture = Create([surface]);
fixture.Camera.ToggleFly();
fixture.Owner.AttachRaw();
Action<Vector2> copied = surface.Callback!;
float yaw = fixture.Camera.Fly.Yaw;
fixture.Owner.Deactivate();
copied(new Vector2(100, 0));
fixture.Camera.ToggleFly();
fixture.Owner.Dispose();
Assert.Equal(yaw, fixture.Camera.Fly.Yaw);
Assert.True(fixture.Owner.IsDisposalComplete);
}
[Fact]
public void ProcessCloseCutoffDoesNotEndMouseLookUntilSessionRetirement()
{
var fixture = Create([new RawSurface()]);
var mouseLook = new MouseLook(active: true);
var frame = new GameplayInputFrameController(
dispatcher: null,
new DispatcherMovementInputSource(),
mouseLook,
new Combat());
fixture.Owner.BindGameplayFrame(frame);
fixture.Owner.AttachRaw();
fixture.Owner.Deactivate();
fixture.Owner.Dispose();
Assert.Equal(0, mouseLook.LifecycleEndCalls);
fixture.Owner.ReleaseMouseLookAfterSessionRetirement();
Assert.Equal(1, mouseLook.LifecycleEndCalls);
}
[Fact]
public void FailedRawDetachRetriesOnlyPendingEdge()
{
var surface = new RawSurface();
var fixture = Create([surface]);
fixture.Owner.AttachRaw();
surface.FailRemoveOnce = true;
fixture.Owner.Dispose();
int removes = surface.RemoveCalls;
fixture.Owner.Dispose();
Assert.Equal(2, surface.RemoveCalls);
Assert.Equal(removes, surface.RemoveCalls);
Assert.True(fixture.Owner.IsDisposalComplete);
}
[Fact]
public void DisposeBeforeAttachMakesPointerOwnerTerminal()
{
var surface = new RawSurface();
var fixture = Create([surface]);
fixture.Owner.Dispose();
Assert.Throws<ObjectDisposedException>(fixture.Owner.AttachRaw);
fixture.Owner.Dispose();
Assert.Equal(0, surface.LiveEdges);
}
[Fact]
public void ScrollAndSensitivityPreserveModeSpecificPolicy()
{
var fixture = Create([new RawSurface()]);
float orbitDistance = fixture.Camera.Orbit.Distance;
fixture.Owner.HandleScroll(InputAction.ScrollUp);
string orbit = fixture.Owner.AdjustSensitivity(1.2f);
fixture.Camera.ToggleFly();
float flyBefore = fixture.Owner.ActiveSensitivity;
string fly = fixture.Owner.AdjustSensitivity(1.2f);
Assert.Equal(Math.Clamp(orbitDistance - 20f, 50f, 2000f), fixture.Camera.Orbit.Distance);
Assert.Equal("Orbit sens 1.200x", orbit);
Assert.Equal("Fly sens 1.200x", fly);
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
}
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
{
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
var capture = new Capture();
var mouse = new Mouse();
var cursor = new Cursor();
var owner = new CameraPointerInputController(
surfaces,
cursor,
new HostQuiescenceGate(),
capture,
new LocalPlayerModeState(),
camera,
new ChaseCameraInputState(),
mouse,
new PointerPositionState(),
new Clock());
return new Fixture(owner, camera, capture, cursor);
}
private sealed record Fixture(
CameraPointerInputController Owner,
CameraController Camera,
Capture Capture,
Cursor Cursor);
private sealed class RawSurface : IRawPointerSurface
{
public bool FailAdd { get; set; }
public bool FailRemoveOnce { get; set; }
public bool RaiseDuringAdd { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<Vector2>? Callback { get; private set; }
public void AddMouseMove(Action<Vector2> callback)
{
AddCalls++;
Callback = callback;
LiveEdges++;
if (RaiseDuringAdd) callback(new Vector2(50, 25));
if (FailAdd) throw new InvalidOperationException("add");
}
public void RemoveMouseMove(Action<Vector2> callback)
{
RemoveCalls++;
if (FailRemoveOnce)
{
FailRemoveOnce = false;
throw new InvalidOperationException("remove");
}
if (ReferenceEquals(Callback, callback))
{
Callback = null;
LiveEdges--;
}
}
public void Raise(Vector2 position) => Callback?.Invoke(position);
}
private sealed class Cursor : IPointerCursorModeTarget
{
public CursorMode CursorMode { get; set; }
}
private sealed class Capture : IInputCaptureSource
{
public bool Mouse { get; set; }
public bool WantCaptureMouse => Mouse;
public bool WantCaptureKeyboard => false;
public bool DevToolsWantCaptureKeyboard => false;
}
private sealed class Mouse : IMouseSource
{
#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;
}
private sealed class Clock : IInputMonotonicClock
{
public float NowSeconds => 1f;
}
private sealed class MouseLook(bool active) : IMouseLookInputFrameController
{
public bool Active { get; } = active;
public int LifecycleEndCalls { get; private set; }
public bool HandlePointerAction(InputAction action, ActivationType activation) => false;
public void QueueRawDelta(float dx, float dy) { }
public void Tick() { }
public void EndAndRestoreCursor() { }
public void EndForLifecycle() => LifecycleEndCalls++;
public void ResetSession() { }
}
private sealed class Combat : ICombatInputFrameController
{
public void Tick() { }
public void HandleMovementInput(InputAction action, ActivationType activation) { }
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
}
}

View file

@ -0,0 +1,53 @@
using AcDream.App.Input;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Tests.Input;
public sealed class DispatcherCameraInputSourceTests
{
[Fact]
public void ExactUnbindRestoresNeutralCameraSampling()
{
var keyboard = new Keyboard();
var mouse = new Mouse();
var dispatcher = InputDispatcher.CreateDetached(keyboard, mouse, new KeyBindings());
dispatcher.Attach();
var other = InputDispatcher.CreateDetached(new Keyboard(), new Mouse(), new KeyBindings());
other.Attach();
var source = new DispatcherCameraInputSource();
source.Bind(dispatcher);
dispatcher.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
source.Unbind(other);
Assert.True(source.CaptureFly().Forward);
source.Unbind(dispatcher);
Assert.False(source.IsAvailable);
Assert.Equal(default, source.CaptureFly());
Assert.Equal(default, source.CaptureChaseAdjustment());
}
private sealed class Keyboard : IKeyboardSource
{
#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 Mouse : IMouseSource
{
#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

@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests
Assert.Throws<InvalidOperationException>(() => source.Bind(second)); Assert.Throws<InvalidOperationException>(() => source.Bind(second));
} }
[Fact]
public void UnbindRequiresExactDispatcherAndRestoresNeutralCapture()
{
var source = new DispatcherMovementInputSource();
var (first, _, _) = CreateDispatcher();
var (other, _, _) = CreateDispatcher();
source.Bind(first);
first.TrySetAutomationActionHeld(InputAction.MovementForward, held: true);
source.Unbind(other);
Assert.True(source.Capture().Forward);
source.Unbind(first);
Assert.False(source.IsAvailable);
Assert.Equal(default, source.Capture());
}
private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse) private static (InputDispatcher Dispatcher, FakeKeyboard Keyboard, FakeMouse Mouse)
CreateDispatcher() CreateDispatcher()
{ {
var keyboard = new FakeKeyboard(); var keyboard = new FakeKeyboard();
var mouse = new FakeMouse(); var mouse = new FakeMouse();
return (new InputDispatcher(keyboard, mouse, new KeyBindings()), keyboard, mouse); var dispatcher = InputDispatcher.CreateDetached(
keyboard,
mouse,
new KeyBindings());
dispatcher.Attach();
return (dispatcher, keyboard, mouse);
} }
private sealed class FakeKeyboard : IKeyboardSource private sealed class FakeKeyboard : IKeyboardSource

View file

@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests
new KeyChord(Key.W, ModifierMask.None), new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward, InputAction.MovementForward,
ActivationType.Hold)); ActivationType.Hold));
var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings); var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings);
dispatcher.Attach();
keyboard.Press(Key.W); keyboard.Press(Key.W);
dispatcher.Fired += (_, activation) => dispatcher.Fired += (_, activation) =>
{ {

View file

@ -0,0 +1,344 @@
using System.Reflection;
using AcDream.App.Input;
using AcDream.App.Rendering;
using Silk.NET.Input;
namespace AcDream.App.Tests.Input;
public sealed class QuiescentInputContextTests
{
[Fact]
public void EveryFrontendAddFailureRetainsTheCompletePossiblyAttachedPrefix()
{
for (int failAdd = 1; failAdd <= 10; failAdd++)
{
var fixture = new Fixture { FailAdd = failAdd };
var context = fixture.CreateContext();
Assert.Throws<InvalidOperationException>(() => SubscribeAll(context, () => { }));
context.Dispose();
Assert.Equal(failAdd, fixture.AddCalls);
Assert.Equal(failAdd, fixture.RemoveCalls);
Assert.Equal(0, fixture.LiveEdges);
Assert.True(context.IsDisposalComplete);
Assert.False(fixture.InnerDisposed);
}
}
[Fact]
public void EveryFrontendRemoveFailureRetriesOnlyThePendingEdge()
{
for (int failRemoveEdge = 1; failRemoveEdge <= 10; failRemoveEdge++)
{
var fixture = new Fixture();
var context = fixture.CreateContext();
SubscribeAll(context, () => { });
context.Activate();
fixture.FailRemoveEdgeOnce = failRemoveEdge;
context.Dispose();
int[] completedCounts = fixture.RemoveCounts.ToArray();
context.Dispose();
for (int edge = 1; edge <= 10; edge++)
{
Assert.Equal(
edge == failRemoveEdge ? 2 : 1,
completedCounts[edge]);
Assert.Equal(completedCounts[edge], fixture.RemoveCounts[edge]);
}
Assert.Equal(0, fixture.LiveEdges);
Assert.True(context.IsDisposalComplete);
Assert.False(fixture.InnerDisposed);
}
}
[Fact]
public void DeactivateSilencesKeyboardMouseAndConnectionRelaysBeforePhysicalDetach()
{
var fixture = new Fixture();
var context = fixture.CreateContext();
int calls = 0;
SubscribeAll(context, () => calls++);
context.Activate();
fixture.RaiseAll();
Assert.Equal(10, calls);
context.Deactivate();
fixture.RaiseAll();
context.Dispose();
Assert.Equal(10, calls);
Assert.Equal(0, fixture.LiveEdges);
Assert.True(context.IsDisposalComplete);
}
[Fact]
public void EventRaisedFromAddAccessorCannotEnterPartialFrontend()
{
var fixture = new Fixture { RaiseKeyboardDownDuringAdd = true };
var context = fixture.CreateContext();
int calls = 0;
context.Keyboards[0].KeyDown += (_, _, _) => calls++;
Assert.Equal(0, calls);
context.Activate();
fixture.Keyboard.RaiseDown();
Assert.Equal(1, calls);
}
[Fact]
public void DisposeBeforeFrontendConstructionMakesContextAndDeviceViewsTerminal()
{
var fixture = new Fixture();
var context = fixture.CreateContext();
IKeyboard keyboard = context.Keyboards[0];
IMouse mouse = context.Mice[0];
context.Dispose();
Assert.Throws<ObjectDisposedException>(context.Activate);
Assert.Throws<ObjectDisposedException>(
() => keyboard.KeyDown += static (_, _, _) => { });
Assert.Throws<ObjectDisposedException>(
() => mouse.MouseDown += static (_, _) => { });
Assert.Throws<ObjectDisposedException>(
() => context.ConnectionChanged += static (_, _) => { });
context.Dispose();
Assert.Equal(0, fixture.LiveEdges);
Assert.True(context.IsDisposalComplete);
}
private static void SubscribeAll(QuiescentInputContext context, Action called)
{
IKeyboard keyboard = context.Keyboards[0];
IMouse mouse = context.Mice[0];
keyboard.KeyDown += (_, _, _) => called();
keyboard.KeyUp += (_, _, _) => called();
keyboard.KeyChar += (_, _) => called();
mouse.MouseDown += (_, _) => called();
mouse.MouseUp += (_, _) => called();
mouse.Click += (_, _, _) => called();
mouse.DoubleClick += (_, _, _) => called();
mouse.MouseMove += (_, _) => called();
mouse.Scroll += (_, _) => called();
context.ConnectionChanged += (_, _) => called();
}
private sealed class Fixture
{
private readonly FaultPlan _faults = new();
private Context? _inner;
private IMouse? _mouse;
private MouseProxy? _mouseProxy;
public Keyboard Keyboard { get; private set; } = null!;
public int FailAdd { set => _faults.FailAdd = value; }
public int FailRemoveEdgeOnce { set => _faults.FailRemoveEdgeOnce = value; }
public bool RaiseKeyboardDownDuringAdd { get; set; }
public int AddCalls => _faults.AddCalls;
public int RemoveCalls => _faults.RemoveCalls;
public int[] RemoveCounts => _faults.RemoveCounts;
public int LiveEdges => _faults.LiveEdges;
public bool InnerDisposed => _inner?.Disposed == true;
public QuiescentInputContext CreateContext()
{
Keyboard = new Keyboard(_faults)
{
RaiseDuringAdd = RaiseKeyboardDownDuringAdd,
};
_mouse = DispatchProxy.Create<IMouse, MouseProxy>();
_mouseProxy = (MouseProxy)(object)_mouse;
_mouseProxy.Initialize(_faults, _mouse);
_inner = new Context(_faults, Keyboard, _mouse);
return new QuiescentInputContext(_inner, new HostQuiescenceGate());
}
public void RaiseAll()
{
Keyboard.RaiseAll();
_mouseProxy!.RaiseAll();
_inner!.RaiseConnection(Keyboard);
}
}
public sealed class FaultPlan
{
public int FailAdd { get; set; }
public int FailRemoveEdgeOnce { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public int[] RemoveCounts { get; } = new int[11];
public void Add(int edge, Action sideEffect)
{
AddCalls++;
sideEffect();
LiveEdges++;
if (AddCalls == FailAdd)
throw new InvalidOperationException($"add {edge}");
}
public void Remove(int edge, Action sideEffect)
{
RemoveCalls++;
RemoveCounts[edge]++;
if (FailRemoveEdgeOnce == edge)
{
FailRemoveEdgeOnce = 0;
throw new InvalidOperationException($"remove {edge}");
}
sideEffect();
LiveEdges--;
}
}
private sealed class Context(
FaultPlan faults,
IKeyboard keyboard,
IMouse mouse) : IInputContext
{
private Action<IInputDevice, bool>? _connectionChanged;
public bool Disposed { get; private set; }
public nint Handle => 1;
public IReadOnlyList<IGamepad> Gamepads { get; } = [];
public IReadOnlyList<IJoystick> Joysticks { get; } = [];
public IReadOnlyList<IKeyboard> Keyboards { get; } = [keyboard];
public IReadOnlyList<IMouse> Mice { get; } = [mouse];
public IReadOnlyList<IInputDevice> OtherDevices { get; } = [];
public event Action<IInputDevice, bool>? ConnectionChanged
{
add => faults.Add(10, () => _connectionChanged += value);
remove => faults.Remove(10, () => _connectionChanged -= value);
}
public void Dispose() => Disposed = true;
public void RaiseConnection(IInputDevice device) =>
_connectionChanged?.Invoke(device, true);
}
private sealed class Keyboard(FaultPlan faults) : IKeyboard
{
private Action<IKeyboard, Key, int>? _down;
private Action<IKeyboard, Key, int>? _up;
private Action<IKeyboard, char>? _char;
public bool RaiseDuringAdd { get; init; }
public string Name => "keyboard";
public int Index => 0;
public bool IsConnected => true;
public IReadOnlyList<Key> SupportedKeys { get; } = [];
public string ClipboardText { get; set; } = string.Empty;
public event Action<IKeyboard, Key, int>? KeyDown
{
add => faults.Add(1, () =>
{
_down += value;
if (RaiseDuringAdd)
_down?.Invoke(this, Key.A, 0);
});
remove => faults.Remove(1, () => _down -= value);
}
public event Action<IKeyboard, Key, int>? KeyUp
{
add => faults.Add(2, () => _up += value);
remove => faults.Remove(2, () => _up -= value);
}
public event Action<IKeyboard, char>? KeyChar
{
add => faults.Add(3, () => _char += value);
remove => faults.Remove(3, () => _char -= value);
}
public bool IsKeyPressed(Key key) => false;
public bool IsScancodePressed(int scancode) => false;
public void BeginInput() { }
public void EndInput() { }
public void RaiseDown() => _down?.Invoke(this, Key.A, 0);
public void RaiseAll()
{
_down?.Invoke(this, Key.A, 0);
_up?.Invoke(this, Key.A, 0);
_char?.Invoke(this, 'a');
}
}
public class MouseProxy : DispatchProxy
{
private readonly Dictionary<string, Delegate?> _events = new();
private FaultPlan _faults = null!;
private IMouse _self = null!;
public void Initialize(FaultPlan faults, IMouse self)
{
_faults = faults;
_self = self;
}
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
ArgumentNullException.ThrowIfNull(targetMethod);
args ??= [];
if (targetMethod.Name.StartsWith("add_", StringComparison.Ordinal))
{
string name = targetMethod.Name[4..];
int edge = Edge(name);
_faults.Add(edge, () =>
_events[name] = Delegate.Combine(_events.GetValueOrDefault(name), (Delegate)args[0]!));
return null;
}
if (targetMethod.Name.StartsWith("remove_", StringComparison.Ordinal))
{
string name = targetMethod.Name[7..];
int edge = Edge(name);
_faults.Remove(edge, () =>
_events[name] = Delegate.Remove(_events.GetValueOrDefault(name), (Delegate)args[0]!));
return null;
}
if (targetMethod.Name == nameof(IMouse.IsButtonPressed))
return false;
Type returnType = targetMethod.ReturnType;
if (returnType == typeof(void))
return null;
if (returnType == typeof(string))
return string.Empty;
return returnType.IsValueType ? Activator.CreateInstance(returnType) : null;
}
public void RaiseAll()
{
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left);
Get<Action<IMouse, MouseButton>>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left);
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.Click))
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
Get<Action<IMouse, MouseButton, System.Numerics.Vector2>>(nameof(IMouse.DoubleClick))
?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One);
Get<Action<IMouse, System.Numerics.Vector2>>(nameof(IMouse.MouseMove))
?.Invoke(_self, System.Numerics.Vector2.One);
Get<Action<IMouse, ScrollWheel>>(nameof(IMouse.Scroll))
?.Invoke(_self, new ScrollWheel(1, 1));
}
private T? Get<T>(string name) where T : Delegate =>
_events.GetValueOrDefault(name) as T;
private static int Edge(string name) => name switch
{
nameof(IMouse.MouseDown) => 4,
nameof(IMouse.MouseUp) => 5,
nameof(IMouse.Click) => 6,
nameof(IMouse.DoubleClick) => 7,
nameof(IMouse.MouseMove) => 8,
nameof(IMouse.Scroll) => 9,
_ => throw new InvalidOperationException($"Unexpected mouse event {name}."),
};
}
}

View file

@ -0,0 +1,284 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Tests.Input;
public sealed class SilkInputSourceLifetimeTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new KeyboardSurface { FailAdd = failAdd };
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(source.Attach);
Assert.True(source.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Fact]
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
{
var surface = new KeyboardSurface();
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
int calls = 0;
source.KeyDown += (_, _) => calls++;
source.Attach();
Action<Key> copied = surface.Down!;
surface.RaiseDown(Key.A);
source.Deactivate();
copied(Key.B);
source.Dispose();
Assert.Equal(1, calls);
Assert.True(source.IsDisposalComplete);
}
[Fact]
public void KeyboardEventRaisedReentrantlyDuringAttachCannotEnterPartialSource()
{
var surface = new KeyboardSurface { RaiseDuringAdd = true };
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
int calls = 0;
source.KeyDown += (_, _) => calls++;
source.Attach();
Assert.Equal(0, calls);
surface.RaiseDown(Key.A);
Assert.Equal(1, calls);
}
[Fact]
public void KeyboardDetachRetriesOnlyPendingEdge()
{
var surface = new KeyboardSurface();
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
source.Attach();
surface.FailRemoveDown = true;
Assert.Throws<AggregateException>(source.Dispose);
int upRemoves = surface.UpRemoveCalls;
surface.FailRemoveDown = false;
source.Dispose();
Assert.Equal(upRemoves, surface.UpRemoveCalls);
Assert.True(source.IsDisposalComplete);
}
[Fact]
public void KeyboardDisposeBeforeAttachMakesSourceTerminal()
{
var surface = new KeyboardSurface();
var source = SilkKeyboardSource.CreateDetached(surface, new HostQuiescenceGate());
source.Dispose();
Assert.Throws<ObjectDisposedException>(source.Attach);
source.Dispose();
Assert.Equal(0, surface.LiveEdges);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new MouseSurface { FailAdd = failAdd };
var source = SilkMouseSource.CreateDetached(
surface,
new Capture(),
modifierSource: null,
new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(source.Attach);
Assert.True(source.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Fact]
public void MouseDeactivateSilencesAllCopiedDelegatesBeforePhysicalDetach()
{
var surface = new MouseSurface();
var source = SilkMouseSource.CreateDetached(
surface,
new Capture(),
modifierSource: null,
new HostQuiescenceGate());
int calls = 0;
source.MouseDown += (_, _) => calls++;
source.MouseUp += (_, _) => calls++;
source.MouseMove += (_, _) => calls++;
source.Scroll += _ => calls++;
source.Attach();
var copied = surface.CopyCallbacks();
surface.RaiseAll();
source.Deactivate();
copied.Down(MouseButton.Left);
copied.Up(MouseButton.Left);
copied.Move(new Vector2(5, 6));
copied.Scroll(1);
source.Dispose();
Assert.Equal(4, calls);
Assert.True(source.IsDisposalComplete);
}
[Fact]
public void MouseFailedDetachRetriesOnlyPendingEdge()
{
var surface = new MouseSurface();
var source = SilkMouseSource.CreateDetached(
surface,
new Capture(),
modifierSource: null,
new HostQuiescenceGate());
source.Attach();
surface.FailRemoveMoveOnce = true;
source.Dispose();
int removes = surface.RemoveCalls;
source.Dispose();
Assert.Equal(removes, surface.RemoveCalls);
Assert.Equal(2, surface.MoveRemoveCalls);
Assert.True(source.IsDisposalComplete);
}
[Fact]
public void MouseDisposeBeforeAttachMakesSourceTerminal()
{
var surface = new MouseSurface();
var source = SilkMouseSource.CreateDetached(
surface,
new Capture(),
modifierSource: null,
new HostQuiescenceGate());
source.Dispose();
Assert.Throws<ObjectDisposedException>(source.Attach);
source.Dispose();
Assert.Equal(0, surface.LiveEdges);
}
private sealed class Capture : IInputCaptureSource
{
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
public bool DevToolsWantCaptureKeyboard => false;
}
private sealed class KeyboardSurface : IKeyboardEventSurface
{
public int FailAdd { get; init; }
public bool FailRemoveDown { get; set; }
public bool RaiseDuringAdd { get; init; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int UpRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
private Action<Key>? _down;
private Action<Key>? _up;
public Action<Key>? Down => _down;
public void AddKeyDown(Action<Key> callback) => Add(ref _down, callback);
public void AddKeyUp(Action<Key> callback) => Add(ref _up, callback);
public void RemoveKeyDown(Action<Key> callback)
{
RemoveCalls++;
if (FailRemoveDown) throw new InvalidOperationException("remove down");
if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; }
}
public void RemoveKeyUp(Action<Key> callback)
{
RemoveCalls++;
UpRemoveCalls++;
if (ReferenceEquals(_up, callback)) { _up = null; LiveEdges--; }
}
public bool IsKeyPressed(Key key) => false;
public void RaiseDown(Key key) => _down?.Invoke(key);
private void Add(ref Action<Key>? slot, Action<Key> callback)
{
AddCalls++;
slot = callback;
LiveEdges++;
if (RaiseDuringAdd) callback(Key.A);
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
}
private sealed class MouseSurface : IMouseEventSurface
{
public int FailAdd { get; init; }
public bool FailRemoveMoveOnce { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int MoveRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
private Action<MouseButton>? _down;
private Action<MouseButton>? _up;
private Action<Vector2>? _move;
private Action<float>? _scroll;
public void AddMouseDown(Action<MouseButton> callback) => Add(ref _down, callback);
public void AddMouseUp(Action<MouseButton> callback) => Add(ref _up, callback);
public void AddMouseMove(Action<Vector2> callback) => Add(ref _move, callback);
public void AddScroll(Action<float> callback) => Add(ref _scroll, callback);
public void RemoveMouseDown(Action<MouseButton> callback) => Remove(ref _down, callback);
public void RemoveMouseUp(Action<MouseButton> callback) => Remove(ref _up, callback);
public void RemoveMouseMove(Action<Vector2> callback)
{
MoveRemoveCalls++;
if (FailRemoveMoveOnce)
{
FailRemoveMoveOnce = false;
throw new InvalidOperationException("remove move");
}
Remove(ref _move, callback);
}
public void RemoveScroll(Action<float> callback) => Remove(ref _scroll, callback);
public bool IsButtonPressed(MouseButton button) => false;
public (Action<MouseButton> Down, Action<MouseButton> Up, Action<Vector2> Move, Action<float> Scroll)
CopyCallbacks() => (_down!, _up!, _move!, _scroll!);
public void RaiseAll()
{
_down?.Invoke(MouseButton.Left);
_up?.Invoke(MouseButton.Left);
_move?.Invoke(Vector2.One);
_scroll?.Invoke(1);
}
private void Add<T>(ref Action<T>? slot, Action<T> callback)
{
AddCalls++;
slot = callback;
LiveEdges++;
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove<T>(ref Action<T>? slot, Action<T> callback)
{
RemoveCalls++;
if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; }
}
}
}

View file

@ -0,0 +1,76 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class FramebufferResizeControllerTests
{
[Fact]
public void ResizePreservesViewportAspectCameraDevToolsOrder()
{
var calls = new List<string>();
var aspect = new ViewportAspectState();
var owner = new FramebufferResizeController(aspect);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
owner.Resize(1600, 900);
Assert.Equal(["viewport:1600x900", "camera:1.778", "devtools:1600x900"], calls);
Assert.Equal(1600f / 900f, aspect.Aspect, 5);
}
[Theory]
[InlineData(0, 720)]
[InlineData(1280, 0)]
[InlineData(-1, 720)]
[InlineData(1280, -1)]
public void NonPositiveFramebufferIsRejectedWithoutMutatingTargets(int width, int height)
{
var calls = new List<string>();
var aspect = new ViewportAspectState();
var owner = new FramebufferResizeController(aspect);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
owner.Resize(width, height);
Assert.Empty(calls);
Assert.Equal(16f / 9f, aspect.Aspect);
}
[Fact]
public void LateBindingDoesNotReplayEarlierResize()
{
var calls = new List<string>();
var owner = new FramebufferResizeController(new ViewportAspectState());
owner.Resize(1024, 768);
owner.BindViewport(new Viewport(calls));
owner.BindCamera(new Camera(calls));
owner.BindDevTools(new DevTools(calls));
Assert.Empty(calls);
owner.Resize(800, 600);
Assert.Equal(["viewport:800x600", "camera:1.333", "devtools:800x600"], calls);
}
private sealed class Viewport(List<string> calls) : IFramebufferViewportTarget
{
public void ResizeViewport(int width, int height) =>
calls.Add($"viewport:{width}x{height}");
}
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
{
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
}
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
{
public void ResetLayout(int width, int height) =>
calls.Add($"devtools:{width}x{height}");
}
}

View file

@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests
Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source);
Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", source);
Assert.Contains("new DisplayFramePacingController(", source); Assert.Contains("new DisplayFramePacingController(", source);
Assert.Contains("_inputCapture.WantCaptureMouse", source); string pointerSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Input",
"CameraPointerInputController.cs"));
Assert.Contains("_capture.WantCaptureMouse", pointerSource);
} }
[Fact] [Fact]

View file

@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests
body, body,
"_gl = GL.GetApi(_window!);", "_gl = GL.GetApi(_window!);",
"_input = _window!.CreateInput();", "_input = _window!.CreateInput();",
"_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);", "_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(",
"_mouseSource = new AcDream.App.Input.SilkMouseSource(", "_kbSource.Attach();",
"_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(", "_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(",
"_mouseSource.Attach();",
"_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(",
"_inputDispatcher.Attach();",
"_movementInput.Bind(_inputDispatcher);", "_movementInput.Bind(_inputDispatcher);",
"_cameraInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);",
"_inputDispatcher.Fired += OnInputAction;", "_inputDispatcher.Fired += OnInputAction;",
"mouse.MouseMove +=",
"_cameraController = new CameraController(orbit, fly);", "_cameraController = new CameraController(orbit, fly);",
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
"_cameraPointerInput.AttachRaw();",
"_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);",
"LoadAndApplyPersistedSettings();", "LoadAndApplyPersistedSettings();",
"_uiHost = new AcDream.App.UI.UiHost(", "_uiHost = new AcDream.App.UI.UiHost(",
@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests
"_liveSessionHost = CreateLiveSessionHost();", "_liveSessionHost = CreateLiveSessionHost();",
"_liveSessionHost.Start(_options)"); "_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal);
Assert.Contains("if (firstMouse is not null)", body, StringComparison.Ordinal);
Assert.Contains(
"if (_kbSource is not null && _mouseSource is not null)",
body,
StringComparison.Ordinal);
Assert.DoesNotContain(
"if (firstKb is not null && firstMouse is not null)",
body,
StringComparison.Ordinal);
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal); int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
string postStart = body[start..]; string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal); Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests
} }
[Fact] [Fact]
public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder() public void FramebufferResize_IsOneTypedOwnerHandoff()
{ {
string load = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
string body = MethodBody( string body = MethodBody(
"private void OnFramebufferResize(", "private void OnFramebufferResize(",
"private void ApplyDisplayWindowState("); "private void ApplyDisplayWindowState(");
Assert.Contains("=> _framebufferResize.Resize(newSize);", body, StringComparison.Ordinal);
Assert.DoesNotContain("Viewport(", body, StringComparison.Ordinal);
Assert.DoesNotContain("SetAspect(", body, StringComparison.Ordinal);
Assert.DoesNotContain("ResetLayout(", body, StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(
body, load,
"if (newSize.X <= 0 || newSize.Y <= 0) return;", "_framebufferResize.BindViewport(",
"_gl?.Viewport(", "_framebufferResize.BindCamera(",
"_viewportAspect.Update(", "_framebufferResize.Resize(_window!.FramebufferSize);");
"_cameraController?.SetAspect(", Assert.DoesNotContain(
"_devToolsFramePresenter?.ResetLayout("); "_viewportAspect.Update(_window",
Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal); load,
StringComparison.Ordinal);
} }
[Fact] [Fact]
@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests
string update = Slice( string update = Slice(
source, source,
"private void OnUpdate(double dt)", "private void OnUpdate(double dt)",
"private void OnCameraModeChanged("); "private void OnRender(double deltaSeconds)");
string render = Slice( string render = Slice(
source, source,
"private void OnRender(double deltaSeconds)", "private void OnRender(double deltaSeconds)",
@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests
"_renderFrameOrchestrator!.Render(", "_renderFrameOrchestrator!.Render(",
"new AcDream.App.Rendering.RenderFrameInput("); "new AcDream.App.Rendering.RenderFrameInput(");
Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal);
AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); Assert.Contains(
"=> _cameraPointerInput?.HandleFocusChanged(focused);",
focus,
StringComparison.Ordinal);
AssertAppearsInOrder( AssertAppearsInOrder(
close, close,
"_hostQuiescence.StopAccepting();", "_hostQuiescence.StopAccepting();",
@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests
string[] stages = string[] stages =
[ [
"new ResourceShutdownStage(\"input callback deactivation\"",
"new ResourceShutdownStage(\"session lifetime\"", "new ResourceShutdownStage(\"session lifetime\"",
"new ResourceShutdownStage(\"input callback detach\"",
"new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"", "new ResourceShutdownStage(\"live entities\"",
@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests
"binding.Dispose();", "binding.Dispose();",
"if (!binding.IsDisposalComplete)", "if (!binding.IsDisposalComplete)",
"_windowCallbacks = null;"); "_windowCallbacks = null;");
AssertAppearsInOrder(
manifest,
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
"new ResourceShutdownStage(\"session lifetime\"",
"controller.Dispose();",
"new ResourceShutdownStage(\"input callback detach\"",
"pointer.Dispose();",
"new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();",
"pointer.UnbindGameplayFrame(gameplayFrame);",
"_cameraPointerInput = null;");
AssertAppearsInOrder( AssertAppearsInOrder(
dispose, dispose,
"CompleteShutdown();", "CompleteShutdown();",

View file

@ -0,0 +1,262 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using Silk.NET.Input;
namespace AcDream.App.Tests.UI;
public sealed class RetainedUiInputBindingTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MouseAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new MouseSurface { FailAdd = failAdd };
var binding = new RetainedMouseInputBinding(
surface,
new UiRoot(),
new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void KeyboardAttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var surface = new KeyboardSurface { FailAdd = failAdd };
var binding = new RetainedKeyboardInputBinding(
surface,
new UiRoot(),
new HostQuiescenceGate());
Assert.ThrowsAny<Exception>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Equal(failAdd, surface.AddCalls);
Assert.Equal(failAdd, surface.RemoveCalls);
Assert.Equal(0, surface.LiveEdges);
}
[Fact]
public void MouseDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
{
var surface = new MouseSurface();
var root = new UiRoot();
int worldClicks = 0;
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
var binding = new RetainedMouseInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Action<MouseButton, int, int> copied = surface.Down!;
copied(MouseButton.Left, 10, 20);
binding.Deactivate();
copied(MouseButton.Left, 10, 20);
binding.Dispose();
Assert.Equal(1, worldClicks);
}
[Fact]
public void MouseEventRaisedReentrantlyDuringAttachCannotEnterPartialUiBinding()
{
var surface = new MouseSurface { RaiseDuringAdd = true };
var root = new UiRoot();
int worldClicks = 0;
root.WorldMouseFallThrough += (_, _, _, _) => worldClicks++;
var binding = new RetainedMouseInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Assert.Equal(0, worldClicks);
surface.Down!(MouseButton.Left, 1, 2);
Assert.Equal(1, worldClicks);
}
[Fact]
public void KeyboardDeactivateSilencesCopiedDelegateBeforePhysicalDetach()
{
var surface = new KeyboardSurface();
var root = new UiRoot();
int worldKeys = 0;
root.WorldKeyFallThrough += (_, _) => worldKeys++;
var binding = new RetainedKeyboardInputBinding(
surface,
root,
new HostQuiescenceGate());
binding.Attach();
Action<Key> copied = surface.Down!;
copied(Key.A);
binding.Deactivate();
copied(Key.B);
binding.Dispose();
Assert.Equal(1, worldKeys);
}
[Fact]
public void RetainedBindingsAreTerminalAfterDisposeBeforeAttach()
{
var mouseSurface = new MouseSurface();
var mouse = new RetainedMouseInputBinding(
mouseSurface,
new UiRoot(),
new HostQuiescenceGate());
var keyboardSurface = new KeyboardSurface();
var keyboard = new RetainedKeyboardInputBinding(
keyboardSurface,
new UiRoot(),
new HostQuiescenceGate());
mouse.Dispose();
keyboard.Dispose();
Assert.Throws<ObjectDisposedException>(mouse.Attach);
Assert.Throws<ObjectDisposedException>(keyboard.Attach);
mouse.Dispose();
keyboard.Dispose();
Assert.Equal(0, mouseSurface.LiveEdges);
Assert.Equal(0, keyboardSurface.LiveEdges);
}
[Fact]
public void RetainedBindingsRetryOnlyFailedPhysicalEdges()
{
var mouseSurface = new MouseSurface();
var mouse = new RetainedMouseInputBinding(
mouseSurface,
new UiRoot(),
new HostQuiescenceGate());
var keyboardSurface = new KeyboardSurface();
var keyboard = new RetainedKeyboardInputBinding(
keyboardSurface,
new UiRoot(),
new HostQuiescenceGate());
mouse.Attach();
keyboard.Attach();
mouseSurface.FailRemoveMoveOnce = true;
keyboardSurface.FailRemoveUpOnce = true;
mouse.Dispose();
keyboard.Dispose();
int mouseRemoves = mouseSurface.RemoveCalls;
int keyboardRemoves = keyboardSurface.RemoveCalls;
mouse.Dispose();
keyboard.Dispose();
Assert.Equal(mouseRemoves, mouseSurface.RemoveCalls);
Assert.Equal(keyboardRemoves, keyboardSurface.RemoveCalls);
Assert.Equal(2, mouseSurface.MoveRemoveCalls);
Assert.Equal(2, keyboardSurface.UpRemoveCalls);
Assert.True(mouse.IsDisposalComplete);
Assert.True(keyboard.IsDisposalComplete);
}
private sealed class MouseSurface : IRetainedMouseSurface
{
public int FailAdd { get; init; }
public bool RaiseDuringAdd { get; init; }
public bool FailRemoveMoveOnce { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int MoveRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<MouseButton, int, int>? Down { get; private set; }
private Action<MouseButton, int, int>? _up;
private Action<int, int>? _move;
private Action<int>? _scroll;
public void AddMouseDown(Action<MouseButton, int, int> callback) => Add(() => Down = callback);
public void AddMouseUp(Action<MouseButton, int, int> callback) => Add(() => _up = callback);
public void AddMouseMove(Action<int, int> callback) => Add(() => _move = callback);
public void AddScroll(Action<int> callback) => Add(() => _scroll = callback);
public void RemoveMouseDown(Action<MouseButton, int, int> callback) => Remove(() => Down = null);
public void RemoveMouseUp(Action<MouseButton, int, int> callback) => Remove(() => _up = null);
public void RemoveMouseMove(Action<int, int> callback)
{
MoveRemoveCalls++;
if (FailRemoveMoveOnce)
{
FailRemoveMoveOnce = false;
throw new InvalidOperationException("remove move");
}
Remove(() => _move = null);
}
public void RemoveScroll(Action<int> callback) => Remove(() => _scroll = null);
private void Add(Action publish)
{
AddCalls++;
publish();
LiveEdges++;
if (RaiseDuringAdd)
Down?.Invoke(MouseButton.Left, 1, 2);
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove(Action clear)
{
RemoveCalls++;
clear();
LiveEdges--;
}
}
private sealed class KeyboardSurface : IRetainedKeyboardSurface
{
public int FailAdd { get; init; }
public bool FailRemoveUpOnce { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int UpRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<Key>? Down { get; private set; }
private Action<Key>? _up;
private Action<char>? _char;
public void AddKeyDown(Action<Key> callback) => Add(() => Down = callback);
public void AddKeyUp(Action<Key> callback) => Add(() => _up = callback);
public void AddKeyChar(Action<char> callback) => Add(() => _char = callback);
public void RemoveKeyDown(Action<Key> callback) => Remove(() => Down = null);
public void RemoveKeyUp(Action<Key> callback)
{
UpRemoveCalls++;
if (FailRemoveUpOnce)
{
FailRemoveUpOnce = false;
throw new InvalidOperationException("remove up");
}
Remove(() => _up = null);
}
public void RemoveKeyChar(Action<char> callback) => Remove(() => _char = null);
private void Add(Action publish)
{
AddCalls++;
publish();
LiveEdges++;
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove(Action clear)
{
RemoveCalls++;
clear();
LiveEdges--;
}
}
}

View file

@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests
private static SettingsVM CreateSettings() private static SettingsVM CreateSettings()
{ {
var dispatcher = new InputDispatcher( var dispatcher = InputDispatcher.CreateDetached(
new NullKeyboardSource(), new NullKeyboardSource(),
new NullMouseSource(), new NullMouseSource(),
new KeyBindings()); new KeyBindings());
dispatcher.Attach();
return new SettingsVM( return new SettingsVM(
new KeyBindings(), new KeyBindings(),
dispatcher, dispatcher,

View file

@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.DoesNotContain("wantCaptureMouse: ()", source, Assert.DoesNotContain("wantCaptureMouse: ()", source,
StringComparison.Ordinal); StringComparison.Ordinal);
Assert.Equal(2, CountOccurrences( Assert.DoesNotContain(
source, "_gameplayInputFrame?.EndMouseLook",
"_gameplayInputFrame?.EndMouseLook();"));
Assert.Contains(
"new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())",
source, source,
StringComparison.Ordinal); StringComparison.Ordinal);
string pointerSource = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Input",
"CameraPointerInputController.cs"));
Assert.Contains("_gameplayFrame?.EndMouseLook();", pointerSource,
StringComparison.Ordinal);
string teleportSource = File.ReadAllText(Path.Combine( string teleportSource = File.ReadAllText(Path.Combine(
FindRepoRoot(), FindRepoRoot(),
"src", "src",
@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests
AssertAppearsInOrder( AssertAppearsInOrder(
source, source,
"private void OnFocusChanged(bool focused)", "private void OnFocusChanged(bool focused)",
"if (!focused)", "_cameraPointerInput?.HandleFocusChanged(focused);");
"_gameplayInputFrame?.EndMouseLook();");
} }
[Fact] [Fact]
@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests
"private void OnUpdate(double dt)", "private void OnUpdate(double dt)",
"_frameProfiler.BeginStage(", "_frameProfiler.BeginStage(",
"_updateFrameOrchestrator.Tick(", "_updateFrameOrchestrator.Tick(",
"private void OnCameraModeChanged"); "private void OnRender(double deltaSeconds)");
} }
[Fact] [Fact]

View file

@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb(); var kb = new FakeKb();
var mouse = new FakeMouse(); var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults(); var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W); kb.Press(Key.W);
@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb(); var kb = new FakeKb();
var mouse = new FakeMouse(); var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults(); var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W); kb.Press(Key.W);
// Shift pressed alongside W — real keyboard delivers KeyDown(Shift, // Shift pressed alongside W — real keyboard delivers KeyDown(Shift,
@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb(); var kb = new FakeKb();
var mouse = new FakeMouse(); var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults(); var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
kb.Press(Key.W); kb.Press(Key.W);
Assert.True(BuildInputFromDispatcher(dispatcher).Forward); Assert.True(BuildInputFromDispatcher(dispatcher).Forward);
@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests
var kb = new FakeKb(); var kb = new FakeKb();
var mouse = new FakeMouse(); var mouse = new FakeMouse();
var bindings = KeyBindings.AcdreamCurrentDefaults(); var bindings = KeyBindings.AcdreamCurrentDefaults();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var input = BuildInputFromDispatcher(dispatcher); var input = BuildInputFromDispatcher(dispatcher);
Assert.False(input.Forward); Assert.False(input.Forward);

View file

@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests
var kb = new FakeKeyboardSource(); var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource(); var mouse = new FakeMouseSource();
var bindings = new KeyBindings(); var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>(); var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t)); dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired); return (dispatcher, kb, mouse, bindings, fired);

View file

@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests
bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick)); bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick));
bindings.Add(new Binding(rmbChord, InputAction.SelectRight)); bindings.Add(new Binding(rmbChord, InputAction.SelectRight));
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>(); var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t)); dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, mouse, fired); return (dispatcher, mouse, fired);

View file

@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests
var kb = new FakeKeyboardSource(); var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource(); var mouse = new FakeMouseSource();
var bindings = new KeyBindings(); var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
return (dispatcher, kb, mouse, bindings); return (dispatcher, kb, mouse, bindings);
} }

View file

@ -0,0 +1,187 @@
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public sealed class InputDispatcherLifetimeTests
{
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void AttachFailureRollsBackEveryPossiblyAcquiredPrefix(int failAdd)
{
var source = new ThrowingSources { FailAdd = failAdd };
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
Assert.ThrowsAny<Exception>(dispatcher.Attach);
Assert.True(dispatcher.IsDisposalComplete);
Assert.Equal(failAdd, source.AddCalls);
Assert.Equal(failAdd, source.RemoveCalls);
Assert.Equal(0, source.LiveEdges);
}
[Fact]
public void DeactivateSilencesCopiedSourceDelegateBeforePhysicalDetach()
{
var source = new ThrowingSources();
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
int fired = 0;
dispatcher.Fired += (_, _) => fired++;
dispatcher.Attach();
Action<Key, ModifierMask> copied = source.KeyDownDelegate!;
copied(Key.W, ModifierMask.None);
dispatcher.Deactivate();
copied(Key.W, ModifierMask.None);
dispatcher.Dispose();
Assert.Equal(1, fired);
Assert.True(dispatcher.IsDisposalComplete);
}
[Fact]
public void SourceEventRaisedReentrantlyDuringAttachCannotEnterPartialDispatcher()
{
var source = new ThrowingSources { RaiseDuringKeyDownAdd = true };
var bindings = new KeyBindings();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementForward));
var dispatcher = InputDispatcher.CreateDetached(source, source, bindings);
int fired = 0;
dispatcher.Fired += (_, _) => fired++;
dispatcher.Attach();
Assert.Equal(0, fired);
source.KeyDownDelegate!(Key.W, ModifierMask.None);
Assert.Equal(1, fired);
}
[Fact]
public void FailedDetachRetainsOnlyPendingSourceForRetry()
{
var source = new ThrowingSources();
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
dispatcher.Attach();
source.FailRemoveKeyDown = true;
Assert.Throws<AggregateException>(dispatcher.Dispose);
int completedRemoves = source.RemoveCalls - source.KeyDownRemoveCalls;
source.FailRemoveKeyDown = false;
dispatcher.Dispose();
Assert.Equal(completedRemoves, source.RemoveCalls - source.KeyDownRemoveCalls);
Assert.True(dispatcher.IsDisposalComplete);
}
[Fact]
public void DisposeBeforeAttachMakesDispatcherTerminal()
{
var source = new ThrowingSources();
var dispatcher = InputDispatcher.CreateDetached(
source,
source,
new KeyBindings());
dispatcher.Dispose();
Assert.Throws<ObjectDisposedException>(dispatcher.Attach);
dispatcher.Dispose();
Assert.Equal(0, source.AddCalls);
Assert.Equal(0, source.LiveEdges);
}
private sealed class ThrowingSources : IKeyboardSource, IMouseSource
{
private Action<Key, ModifierMask>? _keyDown;
private Action<Key, ModifierMask>? _keyUp;
private Action<MouseButton, ModifierMask>? _mouseDown;
private Action<MouseButton, ModifierMask>? _mouseUp;
private Action<float>? _scroll;
public int FailAdd { get; init; }
public bool FailRemoveKeyDown { get; set; }
public bool RaiseDuringKeyDownAdd { get; init; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public int KeyDownRemoveCalls { get; private set; }
public int LiveEdges { get; private set; }
public Action<Key, ModifierMask>? KeyDownDelegate => _keyDown;
public event Action<Key, ModifierMask>? KeyDown
{
add
{
Add(ref _keyDown, value!);
if (RaiseDuringKeyDownAdd)
value?.Invoke(Key.W, ModifierMask.None);
}
remove
{
KeyDownRemoveCalls++;
if (FailRemoveKeyDown) throw new InvalidOperationException("remove key down");
Remove(ref _keyDown, value!);
}
}
public event Action<Key, ModifierMask>? KeyUp
{
add => Add(ref _keyUp, value!);
remove => Remove(ref _keyUp, value!);
}
public event Action<MouseButton, ModifierMask>? MouseDown
{
add => Add(ref _mouseDown, value!);
remove => Remove(ref _mouseDown, value!);
}
public event Action<MouseButton, ModifierMask>? MouseUp
{
add => Add(ref _mouseUp, value!);
remove => Remove(ref _mouseUp, value!);
}
#pragma warning disable CS0067
public event Action<float, float>? MouseMove;
#pragma warning restore CS0067
public event Action<float>? Scroll
{
add => Add(ref _scroll, value!);
remove => Remove(ref _scroll, value!);
}
public ModifierMask CurrentModifiers => ModifierMask.None;
public bool WantCaptureMouse => false;
public bool WantCaptureKeyboard => false;
public bool IsHeld(Key key) => false;
public bool IsHeld(MouseButton button) => false;
private void Add<T>(ref T? slot, T value) where T : Delegate
{
AddCalls++;
slot = (T?)Delegate.Combine(slot, value);
LiveEdges++;
if (AddCalls == FailAdd) throw new InvalidOperationException("add");
}
private void Remove<T>(ref T? slot, T value) where T : Delegate
{
RemoveCalls++;
if (slot is null) return;
slot = (T?)Delegate.Remove(slot, value);
LiveEdges--;
}
}
}

View file

@ -13,7 +13,8 @@ public class InputDispatcherTests
var kb = new FakeKeyboardSource(); var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource(); var mouse = new FakeMouseSource();
var bindings = new KeyBindings(); var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>(); var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t)); dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired); return (dispatcher, kb, mouse, bindings, fired);

View file

@ -28,7 +28,8 @@ public sealed class SettingsPanelTests
var persisted = new KeyBindings(); var persisted = new KeyBindings();
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
var dispatcher = new InputDispatcher(kb, mouse, persisted); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var vm = new SettingsVM( var vm = new SettingsVM(
persisted, dispatcher, _ => { }, persisted, dispatcher, _ => { },
DisplaySettings.Default, _ => { }, DisplaySettings.Default, _ => { },

View file

@ -22,7 +22,8 @@ public sealed class SettingsVMTests
persisted ??= MakeMinimalBindings(); persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource(); var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource(); var mouse = new FakeMouseSource();
var dispatcher = new InputDispatcher(kb, mouse, persisted); var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted);
dispatcher.Attach();
var savedHistory = new System.Collections.Generic.List<KeyBindings>(); var savedHistory = new System.Collections.Generic.List<KeyBindings>();
var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>(); var savedDisplayHistory = new System.Collections.Generic.List<DisplaySettings>();
var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>(); var savedAudioHistory = new System.Collections.Generic.List<AudioSettings>();