From 8b8afeefa3e0688b5fc06cbd58273be84ceb335a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 11:59:33 +0200 Subject: [PATCH] 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. --- AGENTS.md | 18 +- CLAUDE.md | 18 +- docs/ISSUES.md | 9 +- docs/architecture/acdream-architecture.md | 10 +- docs/architecture/code-structure.md | 10 + .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 15 +- docs/plans/2026-05-12-milestones.md | 11 +- ...amewindow-slice-8-composition-lifecycle.md | 23 +- memory/project_gamewindow_decomposition.md | 22 +- .../Input/CameraPointerInputController.cs | 449 ++++++++++++++++ .../Input/DispatcherCameraInputSource.cs | 7 + .../Input/DispatcherMovementInputSource.cs | 7 + src/AcDream.App/Input/InputCaptureSources.cs | 20 - .../Input/QuiescentInputContext.cs | 486 ++++++++++++++++++ src/AcDream.App/Input/SilkKeyboardSource.cs | 222 +++++++- src/AcDream.App/Input/SilkMouseSource.cs | 325 +++++++++--- .../Rendering/FramebufferResizeController.cs | 118 +++++ src/AcDream.App/Rendering/GameWindow.cs | 394 ++++++-------- src/AcDream.App/UI/RetainedUiInputBinding.cs | 420 +++++++++++++++ src/AcDream.App/UI/UiHost.cs | 116 +++-- .../Input/InputDispatcher.cs | 140 ++++- .../CameraPointerInputControllerTests.cs | 287 +++++++++++ .../Input/DispatcherCameraInputSourceTests.cs | 53 ++ .../DispatcherMovementInputSourceTests.cs | 24 +- .../GameplayInputFrameControllerTests.cs | 3 +- .../Input/QuiescentInputContextTests.cs | 344 +++++++++++++ .../Input/SilkInputSourceLifetimeTests.cs | 284 ++++++++++ .../FramebufferResizeControllerTests.cs | 76 +++ .../GameWindowRenderLeafCompositionTests.cs | 8 +- .../GameWindowSlice8BoundaryTests.cs | 66 ++- .../UI/RetainedUiInputBindingTests.cs | 262 ++++++++++ .../World/LiveObjectFrameControllerTests.cs | 3 +- .../World/UpdateFrameOrchestratorTests.cs | 20 +- .../DispatcherToMovementIntegrationTests.cs | 12 +- .../Input/InputDispatcherCaptureTests.cs | 3 +- .../Input/InputDispatcherDoubleClickTests.cs | 3 +- .../Input/InputDispatcherIsActionHeldTests.cs | 3 +- .../Input/InputDispatcherLifetimeTests.cs | 187 +++++++ .../Input/InputDispatcherTests.cs | 3 +- .../Panels/Settings/SettingsPanelTests.cs | 3 +- .../Panels/Settings/SettingsVMTests.cs | 3 +- 42 files changed, 4029 insertions(+), 461 deletions(-) create mode 100644 src/AcDream.App/Input/CameraPointerInputController.cs create mode 100644 src/AcDream.App/Input/QuiescentInputContext.cs create mode 100644 src/AcDream.App/Rendering/FramebufferResizeController.cs create mode 100644 src/AcDream.App/UI/RetainedUiInputBinding.cs create mode 100644 tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs create mode 100644 tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs create mode 100644 tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs create mode 100644 tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs create mode 100644 tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs diff --git a/AGENTS.md b/AGENTS.md index 1c2198b6..f3e7514e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful 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 behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -127,15 +127,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–D are also complete: dead host residue is removed, +Slice 8 checkpoints A–E are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT -sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` -is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from -the 15,723-line campaign baseline. The 7,419-test -Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +sky/day group, weather, synchronization, and AdminEnvirons state. +`CameraPointerInputController` and `FramebufferResizeController` now own raw +camera input/focus/scroll and physical framebuffer publication. Silk, +dispatcher, retained-UI, and devtools device edges are reversible, +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 F–L—action routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and diff --git a/CLAUDE.md b/CLAUDE.md index 1a38cd9c..4274735b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful 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 behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. @@ -125,15 +125,19 @@ second GUID dictionary. Slices 5–7 are complete: landblock presentation, update-frame orchestration, and render-frame orchestration all have typed owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; the frame owners have no direct window or anonymous callback-bag ownership. -Slice 8 checkpoints A–D are also complete: dead host residue is removed, +Slice 8 checkpoints A–E are also complete: dead host residue is removed, native-window callback intake has reversible lifetime ownership, and `LiveSessionHost` owns reset/binding composition over the sole canonical session controller. `WorldEnvironmentController` solely owns the clock, DAT -sky/day group, weather, synchronization, and AdminEnvirons state. `GameWindow` -is 4,330 raw lines / 192 fields / 67 methods, down 11,393 lines (72.5%) from -the 15,723-line campaign baseline. The 7,419-test -Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second -synchronized nine-stop soak pass. Slice 8 checkpoints E–L—input/resize, action +sky/day group, weather, synchronization, and AdminEnvirons state. +`CameraPointerInputController` and `FramebufferResizeController` now own raw +camera input/focus/scroll and physical framebuffer publication. Silk, +dispatcher, retained-UI, and devtools device edges are reversible, +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 F–L—action routing, settings, resource ownership, ordered composition, retryable shutdown, soak snapshots, and final connected gates—remain active. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4ccdb45a..38412aca 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,11 +27,12 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 are - complete; Slice 8 checkpoints A–D now own native callbacks, live-session - composition, and world-environment state outside the shell. + complete; Slice 8 checkpoints A–E now own native callbacks, live-session + composition, world-environment state, reversible device input/raw pointer, + and physical framebuffer publication outside the shell. `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration - handoffs and the class is 4,330 lines / 192 fields / 67 methods. Remaining - Slice 8 input/settings/composition/shutdown + handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining + Slice 8 action-routing/settings/composition/shutdown cleanup is active in [`docs/architecture/code-structure.md`](architecture/code-structure.md). This is the behavior-preserving prerequisite before new M4 feature bodies. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 96431340..ea8971d5 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -827,9 +827,13 @@ Current movement/collision ownership: session generation into `LiveSessionController` plus its focused host and routers; Slice 4 moved hydration, teardown, inbound authority, network updates, and reusable motion-runtime policy over canonical - `LiveEntityRuntime`. Slice 5 landblock presentation is next. 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. + `LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the + update/render frame graphs. Slice 8 checkpoints A–E now own the native + 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 the thin-`GameWindow` campaign. The current campaign reuses canonical diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index e5e4e6b3..c53770e1 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -690,6 +690,16 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, 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 F–L remain active, with +gameplay action routing next. + ### 4.4 Exit criteria The campaign is complete when: diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 30eb2abc..29c0347f 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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 | |---|---|---|---|---|---| @@ -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 6–7 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-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` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index fec0c3dc..92dedd0f 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–D are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–E are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- @@ -79,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 process-residency gate's diagnostic blind spot; no tolerance was loosened. -Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–D now own +Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–E now own the frozen shell boundary, all nine native callback edges, live-session reset/selection/entry/route composition, and world clock/sky/day/weather/ -AdminEnvirons state. `GameWindow` is 4,330 lines / 192 fields / 67 methods; -the Checkpoint D Release suite passes 7,419 tests / 5 skips. Input, settings, -startup/resource transfer, and shutdown checkpoints E–L remain. +AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, +dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner; +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 F–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 1c92eb8d..e733d96a 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized nine-destination soak pass with graceful exits, and six stable PNG checkpoints -preserve Slice 6 presentation. Final Slice 8 checkpoints A–D are complete: +preserve Slice 6 presentation. Final Slice 8 checkpoints A–E are complete: native callbacks and live-session composition now have explicit retryable owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, -day group, weather, and AdminEnvirons state. `GameWindow` is 4,330 lines / 192 -fields / 67 methods; 7,419 Release tests pass / 5 skip. Checkpoints E–L remain -active. Issue #232 tracks process-residency variance in the soak without +day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, +transactional ownership for Silk/dispatcher/retained/devtools input, the raw +camera pointer, focus, and physical framebuffer size. Graceful live-session +retirement now precedes physical callback removal. `GameWindow` is 4,266 lines +/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints F–L +remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index 32a8979a..79532b69 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -21,7 +21,7 @@ audit. - [x] C — extract live-session host/reset/binding callbacks and verify the embedded skill formula against named retail. - [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. - [ ] F — extract the sole gameplay input-action router plus focused combat and diagnostic command owners. @@ -497,6 +497,27 @@ this behavior-preserving ownership checkpoint. - Require rollback after every Nth event add and silence between every shutdown 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 - Move the frozen action-priority graph into the sole Fired subscriber. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 96e288f4..156689b9 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -3,9 +3,9 @@ ## Current state The behavior-preserving App ownership campaign is complete through Slice 7 and -Slice 8 checkpoints A–D. `GameWindow.cs` moved from the 2026-07-21 baseline of -15,723 lines / 278 fields / 205 methods to 4,330 lines / 192 fields / 67 -methods: 11,393 lines (72.5%) were removed without changing accepted gameplay +Slice 8 checkpoints A–E. `GameWindow.cs` moved from the 2026-07-21 baseline of +15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65 +methods: 11,457 lines (72.9%) were removed without changing accepted gameplay or rendering behavior. | 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 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | -| 8 A–D | shell freeze, native callbacks, live-session composition, world environment | 4,330 / 192 / 67 | +| 8 A–E | 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 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 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`, `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, @@ -90,6 +99,9 @@ review rule live in `docs/architecture/code-structure.md`. ## 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 skips; App 3,059/3 skips. Focused environment/boundary tests and all three independent corrected-diff reviews are clean. @@ -119,7 +131,7 @@ review rule live in `docs/architecture/code-structure.md`. ## Next work -Slice 8 checkpoints E–L group the remaining input, settings, resource +Slice 8 checkpoints F–L group the remaining action routing, settings, resource ownership, startup, and shutdown wiring and reduce Silk callbacks to narrow calls into input, update, render, resize, focus, and shutdown owners. It removes leftover feature state only where Slices 1–7 already established a diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs new file mode 100644 index 00000000..22d43807 --- /dev/null +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -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; + +/// +/// 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. +/// +internal interface IRawPointerSurface +{ + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); +} + +internal sealed class SilkRawPointerSurface : IRawPointerSurface +{ + private readonly IMouse _mouse; + private Action? _callback; + private readonly Action _mouseMove; + + public SilkRawPointerSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _mouseMove = OnMouseMove; + } + + public void AddMouseMove(Action 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 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; + } +} + +/// +/// 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. +/// +internal sealed class CameraPointerInputController : IDisposable +{ + private readonly IReadOnlyList _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 _mouseMoved; + private readonly Action _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 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 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); + } + } + + /// + /// Completes the deliberate two-phase composition edge. Binding the frame + /// owner never resubscribes or changes event priority. + /// + 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"; + } + + /// + /// 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. + /// + public void Deactivate() + { + Interlocked.Exchange(ref _active, 0); + _chase.RmbOrbitHeld = false; + } + + /// Release presentation state only after live-session retirement. + 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 + { + 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; + } +} diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs index 4b4e982d..dae50088 100644 --- a/src/AcDream.App/Input/DispatcherCameraInputSource.cs +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -43,6 +43,13 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource _dispatcher = dispatcher; } + public void Unbind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (ReferenceEquals(_dispatcher, dispatcher)) + _dispatcher = null; + } + public FlyCameraInput CaptureFly() { if (_dispatcher is not { } dispatcher) diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index b8a2e20f..2d536f02 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -31,6 +31,13 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource _dispatcher = dispatcher; } + public void Unbind(InputDispatcher dispatcher) + { + ArgumentNullException.ThrowIfNull(dispatcher); + if (ReferenceEquals(_dispatcher, dispatcher)) + _dispatcher = null; + } + public MovementInput Capture() { // Devtools owns the whole gameplay keyboard while active, including diff --git a/src/AcDream.App/Input/InputCaptureSources.cs b/src/AcDream.App/Input/InputCaptureSources.cs index 7ec77fe5..0c3ff8a3 100644 --- a/src/AcDream.App/Input/InputCaptureSources.cs +++ b/src/AcDream.App/Input/InputCaptureSources.cs @@ -52,23 +52,3 @@ internal sealed class CompositeInputCaptureSource : IInputCaptureSource public bool DevToolsWantCaptureKeyboard => _devTools.WantCaptureKeyboard; } - -internal sealed class DelegateInputCaptureSource : IInputCaptureSource -{ - private readonly Func _wantCaptureMouse; - private readonly Func _wantCaptureKeyboard; - - public DelegateInputCaptureSource( - Func wantCaptureMouse, - Func 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; -} diff --git a/src/AcDream.App/Input/QuiescentInputContext.cs b/src/AcDream.App/Input/QuiescentInputContext.cs new file mode 100644 index 00000000..a4d1c6ea --- /dev/null +++ b/src/AcDream.App/Input/QuiescentInputContext.cs @@ -0,0 +1,486 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Silk.NET.Input; + +namespace AcDream.App.Input; + +/// +/// 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. +/// +internal sealed class QuiescentInputContext : IInputContext +{ + private readonly IInputContext _inner; + private readonly HostQuiescenceGate _quiescence; + private readonly QuiescentKeyboard[] _keyboards; + private readonly QuiescentMouse[] _mice; + private readonly Action _connectionChanged; + private Action? _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 Gamepads => _inner.Gamepads; + public IReadOnlyList Joysticks => _inner.Joysticks; + public IReadOnlyList Keyboards => _keyboards; + public IReadOnlyList Mice => _mice; + public IReadOnlyList OtherDevices => _inner.OtherDevices; + + public event Action? 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); + + /// + /// 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. + /// + 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(); + 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 _downRelay; + private readonly Action _upRelay; + private readonly Action _charRelay; + private Action? _downSubscribers; + private Action? _upSubscribers; + private Action? _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 SupportedKeys => _inner.SupportedKeys; + public string ClipboardText + { + get => _inner.ClipboardText; + set => _inner.ClipboardText = value; + } + + public event Action? 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? 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? 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 _downRelay; + private readonly Action _upRelay; + private readonly Action _clickRelay; + private readonly Action _doubleClickRelay; + private readonly Action _moveRelay; + private readonly Action _scrollRelay; + private Action? _downSubscribers; + private Action? _upSubscribers; + private Action? _clickSubscribers; + private Action? _doubleClickSubscribers; + private Action? _moveSubscribers; + private Action? _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 SupportedButtons => _inner.SupportedButtons; + public IReadOnlyList 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 MouseDown + { + add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay); + remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay); + } + public event Action MouseUp + { + add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay); + remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay); + } + public event Action Click + { + add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay); + remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay); + } + public event Action DoubleClick + { + add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay); + remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay); + } + public event Action MouseMove + { + add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay); + remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay); + } + public event Action 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(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( + 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; + } + } +} diff --git a/src/AcDream.App/Input/SilkKeyboardSource.cs b/src/AcDream.App/Input/SilkKeyboardSource.cs index 65c1d1dd..bf1c17d1 100644 --- a/src/AcDream.App/Input/SilkKeyboardSource.cs +++ b/src/AcDream.App/Input/SilkKeyboardSource.cs @@ -1,46 +1,218 @@ -using System; +using AcDream.App.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Input; -/// -/// Bridges a Silk.NET to the test-fakeable -/// interface used by -/// . K.1a wiring: GameWindow constructs -/// one of these wrapping the first keyboard from -/// IInputContext.Keyboards; the dispatcher fans events out to -/// subscribers without ever touching Silk types directly. -/// -public sealed class SilkKeyboardSource : IKeyboardSource +internal interface IKeyboardEventSurface +{ + void AddKeyDown(Action callback); + void RemoveKeyDown(Action callback); + void AddKeyUp(Action callback); + void RemoveKeyUp(Action callback); + bool IsKeyPressed(Key key); +} + +internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface { private readonly IKeyboard _keyboard; + private Action? _keyDownCallback; + private Action? _keyUpCallback; + private readonly Action _keyDown; + private readonly Action _keyUp; + + public SilkKeyboardEventSurface(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _keyDown = OnKeyDown; + _keyUp = OnKeyUp; + } + + public void AddKeyDown(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + _keyDownCallback = callback; + _keyboard.KeyDown += _keyDown; + } + + public void RemoveKeyDown(Action callback) + { + if (!ReferenceEquals(_keyDownCallback, callback)) + return; + _keyboard.KeyDown -= _keyDown; + _keyDownCallback = null; + } + + public void AddKeyUp(Action callback) + { + ArgumentNullException.ThrowIfNull(callback); + _keyUpCallback = callback; + _keyboard.KeyUp += _keyUp; + } + + public void RemoveKeyUp(Action 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); +} + +/// +/// 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. +/// +public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable +{ + private readonly IKeyboardEventSurface _surface; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _keyDown; + private readonly Action _keyUp; + private readonly bool[] _attached = new bool[2]; + private ResourceShutdownTransaction? _detach; + private bool _attachStarted; + private int _disposeRequested; + private int _active; public event Action? KeyDown; public event Action? KeyUp; - public SilkKeyboardSource(IKeyboard keyboard) + private SilkKeyboardSource( + IKeyboardEventSurface surface, + HostQuiescenceGate quiescence) { - _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); - _keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers()); - _keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers()); + _surface = surface ?? throw new ArgumentNullException(nameof(surface)); + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); + _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 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() { - ModifierMask m = ModifierMask.None; - if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight)) - m |= ModifierMask.Shift; - if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight)) - m |= ModifierMask.Ctrl; - if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight)) - m |= ModifierMask.Alt; - if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight)) - m |= ModifierMask.Win; - return m; + ModifierMask modifiers = ModifierMask.None; + if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight)) + modifiers |= ModifierMask.Shift; + if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight)) + modifiers |= ModifierMask.Ctrl; + if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight)) + modifiers |= ModifierMask.Alt; + if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight)) + modifiers |= ModifierMask.Win; + 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); } } diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs index 39e29487..a8530a64 100644 --- a/src/AcDream.App/Input/SilkMouseSource.cs +++ b/src/AcDream.App/Input/SilkMouseSource.cs @@ -1,92 +1,295 @@ -using System; +using System.Numerics; +using AcDream.App.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; namespace AcDream.App.Input; -/// -/// Bridges a Silk.NET + the active ImGui IO -/// (for / ) -/// to the test-fakeable used by -/// . -/// -/// -/// Production receives a typed capture source, keeping retained-UI and -/// developer-UI ownership outside the Silk adapter. The public delegate -/// overload remains as a compatibility seam for standalone consumers. -/// -/// -public sealed class SilkMouseSource : IMouseSource +internal interface IMouseEventSurface +{ + void AddMouseDown(Action callback); + void RemoveMouseDown(Action callback); + void AddMouseUp(Action callback); + void RemoveMouseUp(Action callback); + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); + void AddScroll(Action callback); + void RemoveScroll(Action callback); + bool IsButtonPressed(MouseButton button); +} + +internal sealed class SilkMouseEventSurface : IMouseEventSurface { private readonly IMouse _mouse; + private Action? _downCallback; + private Action? _upCallback; + private Action? _moveCallback; + private Action? _scrollCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _scroll; + + public SilkMouseEventSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _down = OnDown; + _up = OnUp; + _move = OnMove; + _scroll = OnScroll; + } + + public void AddMouseDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseDown += _down; + } + + public void RemoveMouseDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _mouse.MouseDown -= _down; + _downCallback = null; + } + + public void AddMouseUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseUp += _up; + } + + public void RemoveMouseUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _mouse.MouseUp -= _up; + _upCallback = null; + } + + public void AddMouseMove(Action callback) + { + _moveCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseMove += _move; + } + + public void RemoveMouseMove(Action callback) + { + if (!ReferenceEquals(_moveCallback, callback)) return; + _mouse.MouseMove -= _move; + _moveCallback = null; + } + + public void AddScroll(Action callback) + { + _scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.Scroll += _scroll; + } + + public void RemoveScroll(Action 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); +} + +/// Reversible Silk mouse bridge with immediate logical cutoff. +public sealed class SilkMouseSource : IMouseSource, IDisposable +{ + private readonly IMouseEventSurface _surface; private readonly IInputCaptureSource _capture; + private readonly HostQuiescenceGate _quiescence; + private readonly Action _mouseDown; + private readonly Action _mouseUp; + private readonly Action _mouseMove; + private readonly Action _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 _lastY; - private bool _haveLastPos; + private bool _haveLastPosition; public event Action? MouseDown; public event Action? MouseUp; public event Action? MouseMove; public event Action? Scroll; - /// The keyboard source that owns the current modifier mask, so - /// mouse events and keyboard events resolve identical chords. 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, IInputCaptureSource capture, - IKeyboardSource modifierSource) - { - _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _capture = capture ?? throw new ArgumentNullException(nameof(capture)); - ModifierSource = modifierSource - ?? throw new ArgumentNullException(nameof(modifierSource)); - Subscribe(); - } + IKeyboardSource? modifierSource, + HostQuiescenceGate quiescence) => + new( + new SilkMouseEventSurface(mouse), + capture, + modifierSource, + quiescence); - public SilkMouseSource( - IMouse mouse, - Func wantCaptureMouse, - Func wantCaptureKeyboard) - { - _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); - _capture = new DelegateInputCaptureSource( - wantCaptureMouse, - wantCaptureKeyboard); - Subscribe(); - } + internal static SilkMouseSource CreateDetached( + IMouseEventSurface surface, + IInputCaptureSource capture, + IKeyboardSource? modifierSource, + HostQuiescenceGate quiescence) => + new(surface, capture, modifierSource, quiescence); - private void Subscribe() + public bool IsDisposalComplete => _attached.All(static value => !value); + + public void Attach() { - _mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers()); - _mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers()); - _mouse.MouseMove += (_, pos) => + ObjectDisposedException.ThrowIf( + Volatile.Read(ref _disposeRequested) != 0, + this); + if (_attachStarted) + throw new InvalidOperationException("Mouse source attachment has already started."); + _attachStarted = true; + try { - float dx, dy; - if (_haveLastPos) - { - dx = pos.X - _lastX; - dy = pos.Y - _lastY; - } - else - { - dx = 0f; - dy = 0f; - _haveLastPos = true; - } - _lastX = pos.X; - _lastY = pos.Y; - MouseMove?.Invoke(dx, dy); - }; - _mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y); + _attached[0] = true; + _surface.AddMouseDown(_mouseDown); + _attached[1] = true; + _surface.AddMouseUp(_mouseUp); + _attached[2] = true; + _surface.AddMouseMove(_mouseMove); + _attached[3] = true; + _surface.AddScroll(_scroll); + Volatile.Write(ref _active, 1); + } + catch (Exception attachError) + { + Deactivate(); + RollBackOrThrow(attachError); + } + } + + 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() => 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; - public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard; + private void OnMouseUp(MouseButton button) => + _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); + } } diff --git a/src/AcDream.App/Rendering/FramebufferResizeController.cs b/src/AcDream.App/Rendering/FramebufferResizeController.cs new file mode 100644 index 00000000..411aba92 --- /dev/null +++ b/src/AcDream.App/Rendering/FramebufferResizeController.cs @@ -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); +} + +/// +/// 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. +/// +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 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(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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3e185b01..17e01e66 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -27,6 +27,7 @@ public sealed class GameWindow : IDisposable private SilkWindowCallbackBinding? _windowCallbacks; private GL? _gl; private IInputContext? _input; + private AcDream.App.Input.QuiescentInputContext? _devToolsInputContext; private TerrainModernRenderer? _terrain; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by /// at draw time but allocated + disposed here. @@ -34,16 +35,7 @@ public sealed class GameWindow : IDisposable private CameraController? _cameraController; private IDatReaderWriter? _dats; private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new(); - private float _lastMouseX - { - get => _pointerPosition.X; - set => _pointerPosition.X = value; - } - private float _lastMouseY - { - get => _pointerPosition.Y; - set => _pointerPosition.Y = value; - } + private AcDream.App.Input.CameraPointerInputController? _cameraPointerInput; private Shader? _meshShader; private TextureCache? _textureCache; /// 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.ViewportAspectState _viewportAspect = new(); + private readonly FramebufferResizeController _framebufferResize; + private IFramebufferDevToolsTarget? _framebufferDevToolsTarget; private AcDream.App.Input.PlayerModeController? _playerModeController; private readonly AcDream.App.Interaction.PlayerApproachCompletionState _playerApproachCompletions = new(); @@ -444,26 +438,6 @@ public sealed class GameWindow : IDisposable // Phase D.2b-C — live character-sheet assembly + raise flow (extracted // feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1. 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 // by LiveSessionHost's entered-world transition; ticked from // OnUpdate; disarmed if the user manually enters fly mode (or any @@ -579,6 +553,7 @@ public sealed class GameWindow : IDisposable _retainedInputCapture); _movementInput = new AcDream.App.Input.DispatcherMovementInputSource( _inputCapture); + _framebufferResize = new FramebufferResizeController(_viewportAspect); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; @@ -676,6 +651,8 @@ public sealed class GameWindow : IDisposable _physicsEngine.DataCache = _physicsDataCache; _gl = GL.GetApi(_window!); + _framebufferResize.BindViewport( + new SilkFramebufferViewportTarget(_gl)); _gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl); var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics( new AcDream.App.Rendering.SilkRenderGlStateReader(_gl), @@ -692,113 +669,36 @@ public sealed class GameWindow : IDisposable // per K.1b plan §D). var firstKb = _input.Keyboards.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); - _mouseSource = new AcDream.App.Input.SilkMouseSource( + _kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached( + firstKb, + _hostQuiescence); + _kbSource.Attach(); + } + if (firstMouse is not null) + { + _mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached( firstMouse, _inputCapture, - _kbSource); - _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( + _kbSource, + _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); + _inputDispatcher.Attach(); _movementInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher); - _mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse); _inputDispatcher.Fired += OnInputAction; Combat.CombatModeChanged += SetInputCombatScope; 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.Enable(EnableCap.DepthTest); @@ -849,10 +749,26 @@ public sealed class GameWindow : IDisposable Console.WriteLine("world-hud font: no system monospace font found"); } - var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; - var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y }; + var orbit = new OrbitCamera(); + var fly = new FlyCamera(); _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); _magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats); @@ -959,8 +875,15 @@ public sealed class GameWindow : IDisposable AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null; try { + _devToolsInputContext = new AcDream.App.Input.QuiescentInputContext( + _input!, + _hostQuiescence); 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(); // VitalsVM: GUID=0 at construction; set later at EnterWorld @@ -1021,9 +944,11 @@ public sealed class GameWindow : IDisposable _worldSceneDebugState.CollisionWireframesVisible, 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, - getRmbOrbit: () => _rmbHeld, + getRmbOrbit: () => + _cameraPointerInput?.RmbOrbitHeld ?? false, getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(), getDayFraction: () => (float)WorldTime.DayFraction, getWeather: () => Weather.Kind.ToString(), @@ -1229,6 +1154,9 @@ public sealed class GameWindow : IDisposable debugPanel, _debugVm, settingsPanel)); + _framebufferDevToolsTarget = new DevToolsFramebufferTarget( + _devToolsFramePresenter); + _framebufferResize.BindDevTools(_framebufferDevToolsTarget); _devToolsFramePresenter.ResetLayout( _window!.Size.X, _window.Size.Y, @@ -1241,6 +1169,13 @@ public sealed class GameWindow : IDisposable // operation. Construction failure still owns this local and // must release it before abandoning the optional frontend. imguiBootstrap?.Dispose(); + if (_framebufferDevToolsTarget is { } framebufferTarget) + { + _framebufferResize.UnbindDevTools(framebufferTarget); + _framebufferDevToolsTarget = null; + } + _devToolsInputContext?.Dispose(); + _devToolsInputContext = null; _devToolsBackend = null; _devToolsFramePresenter = null; _devToolsCameraMenu = null; @@ -1429,7 +1364,11 @@ public sealed class GameWindow : IDisposable if (_options.RetailUi) { _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; _uiHost.Root.UiLocked = _persistedGameplay.LockUI; var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController( @@ -2031,7 +1970,9 @@ public sealed class GameWindow : IDisposable camera.Projection, camera.Viewport); }, - () => new System.Numerics.Vector2(_lastMouseX, _lastMouseY), + () => new System.Numerics.Vector2( + _pointerPosition.X, + _pointerPosition.Y), () => _playerController is { } player ? new AcDream.App.Interaction.PlayerInteractionPose( player.CellId, @@ -2472,6 +2413,7 @@ public sealed class GameWindow : IDisposable mouseLookController, new AcDream.App.Input.CombatAttackInputFrameAdapter( _combatAttackController!)); + _cameraPointerInput?.BindGameplayFrame(_gameplayInputFrame); var streamingFrame = new AcDream.App.Streaming.StreamingFrameController( _options.LiveMode, _localPlayerMode, @@ -2559,7 +2501,6 @@ public sealed class GameWindow : IDisposable _animatedEntities, _equippedChildRenderer!, liveEffectFrame); - _viewportAspect.Update(_window!.Size.X, _window.Size.Y); _playerModeController = new AcDream.App.Input.PlayerModeController( _localPlayerMode, _playerControllerSlot, @@ -3169,31 +3110,6 @@ public sealed class GameWindow : IDisposable 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 // window title so there's zero rendering cost (no font/overlay needed). private void OnRender(double deltaSeconds) @@ -3325,13 +3241,6 @@ public sealed class GameWindow : IDisposable private bool GetDebugPlayerOnGround() => _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; - } - /// /// Cycle the time-of-day debug override. Same body as the old F7 /// 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. /// private void OnFramebufferResize(Silk.NET.Maths.Vector2D 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); - } + => _framebufferResize.Resize(newSize); /// /// 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. Console.WriteLine($"[input] {action} {activation}"); - // RMB-orbit hold: track press/release transitions explicitly so - // _rmbHeld is true exactly while the chord is held. Hold-type - // 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). + // RMB orbit and instant mouse-look remain the first accepted input + // transition. Their state is owned by the gameplay/pointer owners. if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true) return; @@ -3958,20 +3850,10 @@ public sealed class GameWindow : IDisposable /// private void AdjustActiveSensitivity(float factor) { - string modeLabel; - float current; - if (_playerMode && _cameraController?.IsChaseMode == true) - { modeLabel = "Chase"; current = _sensChase; } - 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"); + if (_cameraPointerInput is not { } pointer) + return; + string message = pointer.AdjustSensitivity(factor); + _debugVm?.AddToast(message); } /// @@ -4040,28 +3922,7 @@ public sealed class GameWindow : IDisposable /// retail wheel feel). /// private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction 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); - } - } + => _cameraPointerInput?.HandleScroll(action); private void OnClosing() { @@ -4088,6 +3949,20 @@ public sealed class GameWindow : IDisposable } 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 // reset callback owner, especially LandblockStreamer, must remain live // 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 // owners. Withdraw the graph before the first borrowed owner closes; // the later render-frontend stage still disposes the GL owners. @@ -4126,7 +4052,16 @@ public sealed class GameWindow : IDisposable ]), 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", () => { _retailUiRuntime?.Dispose(); @@ -4196,11 +4131,17 @@ public sealed class GameWindow : IDisposable [ new("developer tools", () => { + if (_framebufferDevToolsTarget is { } framebufferTarget) + { + _framebufferResize.UnbindDevTools(framebufferTarget); + _framebufferDevToolsTarget = null; + } _devToolsFramePresenter = null; _devToolsCameraMenu = null; _devToolsCommandBus = null; _devToolsBackend?.Dispose(); _devToolsBackend = null; + _devToolsInputContext = null; }), new("portal tunnel", () => { @@ -4281,8 +4222,6 @@ public sealed class GameWindow : IDisposable if (ReferenceEquals(_windowCallbacks, binding)) _windowCallbacks = null; }), - new("combat input subscription", () - => Combat.CombatModeChanged -= SetInputCombatScope), new("input context", () => { _input?.Dispose(); @@ -4299,10 +4238,7 @@ public sealed class GameWindow : IDisposable ])); private void OnFocusChanged(bool focused) - { - if (!focused) - _gameplayInputFrame?.EndMouseLook(); - } + => _cameraPointerInput?.HandleFocusChanged(focused); public void Dispose() { diff --git a/src/AcDream.App/UI/RetainedUiInputBinding.cs b/src/AcDream.App/UI/RetainedUiInputBinding.cs new file mode 100644 index 00000000..0cce58e9 --- /dev/null +++ b/src/AcDream.App/UI/RetainedUiInputBinding.cs @@ -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 callback); + void RemoveMouseDown(Action callback); + void AddMouseUp(Action callback); + void RemoveMouseUp(Action callback); + void AddMouseMove(Action callback); + void RemoveMouseMove(Action callback); + void AddScroll(Action callback); + void RemoveScroll(Action callback); +} + +internal sealed class SilkRetainedMouseSurface : IRetainedMouseSurface +{ + private readonly IMouse _mouse; + private Action? _downCallback; + private Action? _upCallback; + private Action? _moveCallback; + private Action? _scrollCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _scroll; + + public SilkRetainedMouseSurface(IMouse mouse) + { + _mouse = mouse ?? throw new ArgumentNullException(nameof(mouse)); + _down = OnDown; + _up = OnUp; + _move = OnMove; + _scroll = OnScroll; + } + + public void AddMouseDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseDown += _down; + } + + public void RemoveMouseDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _mouse.MouseDown -= _down; + _downCallback = null; + } + + public void AddMouseUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseUp += _up; + } + + public void RemoveMouseUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _mouse.MouseUp -= _up; + _upCallback = null; + } + + public void AddMouseMove(Action callback) + { + _moveCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.MouseMove += _move; + } + + public void RemoveMouseMove(Action callback) + { + if (!ReferenceEquals(_moveCallback, callback)) return; + _mouse.MouseMove -= _move; + _moveCallback = null; + } + + public void AddScroll(Action callback) + { + _scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _mouse.Scroll += _scroll; + } + + public void RemoveScroll(Action 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 _down; + private readonly Action _up; + private readonly Action _move; + private readonly Action _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 callback); + void RemoveKeyDown(Action callback); + void AddKeyUp(Action callback); + void RemoveKeyUp(Action callback); + void AddKeyChar(Action callback); + void RemoveKeyChar(Action callback); +} + +internal sealed class SilkRetainedKeyboardSurface : IRetainedKeyboardSurface +{ + private readonly IKeyboard _keyboard; + private Action? _downCallback; + private Action? _upCallback; + private Action? _charCallback; + private readonly Action _down; + private readonly Action _up; + private readonly Action _char; + + public SilkRetainedKeyboardSurface(IKeyboard keyboard) + { + _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); + _down = OnDown; + _up = OnUp; + _char = OnChar; + } + + public void AddKeyDown(Action callback) + { + _downCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyDown += _down; + } + + public void RemoveKeyDown(Action callback) + { + if (!ReferenceEquals(_downCallback, callback)) return; + _keyboard.KeyDown -= _down; + _downCallback = null; + } + + public void AddKeyUp(Action callback) + { + _upCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyUp += _up; + } + + public void RemoveKeyUp(Action callback) + { + if (!ReferenceEquals(_upCallback, callback)) return; + _keyboard.KeyUp -= _up; + _upCallback = null; + } + + public void AddKeyChar(Action callback) + { + _charCallback = callback ?? throw new ArgumentNullException(nameof(callback)); + _keyboard.KeyChar += _char; + } + + public void RemoveKeyChar(Action 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 _down; + private readonly Action _up; + private readonly Action _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); + } +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index dcd233c4..ae48b966 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -49,15 +49,27 @@ public sealed class UiHost : System.IDisposable public IKeyboard? Keyboard { get; private set; } private long _startTicks = System.Environment.TickCount64; - private readonly List _inputUnsubscribers = new(); + private readonly HostQuiescenceGate _quiescence; + private readonly List _inputBindings = new(); + private ResourceShutdownTransaction? _inputShutdown; private ResourceShutdownTransaction? _shutdown; private bool _disposeRequested; private bool _disposed; 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); DefaultFont = defaultFont; + _quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence)); } // ── Per-frame ────────────────────────────────────────────────────── @@ -86,25 +98,21 @@ public sealed class UiHost : System.IDisposable System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(mouse); - void OnMouseDown(IMouse sender, MouseButton button) => - Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); - void OnMouseUp(IMouse sender, MouseButton button) => - Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y); - void OnMouseMove(IMouse sender, Vector2 position) => - Root.OnMouseMove((int)position.X, (int)position.Y); - 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(() => + var binding = new RetainedMouseInputBinding( + new SilkRetainedMouseSurface(mouse), + Root, + _quiescence); + _inputBindings.Add(binding); + try { - mouse.MouseDown -= OnMouseDown; - mouse.MouseUp -= OnMouseUp; - mouse.MouseMove -= OnMouseMove; - mouse.Scroll -= OnScroll; - }); + binding.Attach(); + } + catch + { + if (binding.IsDisposalComplete) + _inputBindings.Remove(binding); + throw; + } } public void WireKeyboard(IKeyboard kb) @@ -112,28 +120,54 @@ public sealed class UiHost : System.IDisposable System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this); System.ArgumentNullException.ThrowIfNull(kb); Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) - void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key); - void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key); - void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value); - - kb.KeyDown += OnKeyDown; - kb.KeyUp += OnKeyUp; - kb.KeyChar += OnKeyChar; - _inputUnsubscribers.Add(() => + var binding = new RetainedKeyboardInputBinding( + new SilkRetainedKeyboardSurface(kb), + Root, + _quiescence); + _inputBindings.Add(binding); + try { - kb.KeyDown -= OnKeyDown; - kb.KeyUp -= OnKeyUp; - kb.KeyChar -= OnKeyChar; - }); + binding.Attach(); + } + catch + { + if (binding.IsDisposalComplete) + _inputBindings.Remove(binding); + throw; + } } - private static UiMouseButton MapButton(MouseButton b) => b switch + /// + /// Stops retained-device delivery without retiring the window tree or GL + /// renderer. Safe to call before a potentially long live-session close. + /// + public void QuiesceInput() { - MouseButton.Left => UiMouseButton.Left, - MouseButton.Right => UiMouseButton.Right, - MouseButton.Middle => UiMouseButton.Middle, - _ => UiMouseButton.Left, - }; + _disposeRequested = true; + foreach (IRetainedUiInputBinding binding in _inputBindings) + binding.Deactivate(); + Keyboard = null; + } + + /// Physically removes every retained input edge after quiescence. + 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) ───────────────── @@ -169,12 +203,8 @@ public sealed class UiHost : System.IDisposable _disposeRequested = true; _shutdown ??= CreateShutdownTransaction( - _inputUnsubscribers.AsEnumerable().Reverse().ToArray(), - () => - { - _inputUnsubscribers.Clear(); - Keyboard = null; - }, + [DeactivateInput], + () => { }, WindowManager.Dispose, TextRenderer.Dispose); _shutdown.CompleteOrThrow(); diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 2c5bd267..fd729585 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -30,7 +30,7 @@ namespace AcDream.UI.Abstractions.Input; /// action stream. /// /// -public sealed class InputDispatcher +public sealed class InputDispatcher : IDisposable { private readonly IKeyboardSource _keyboard; private readonly IMouseSource _mouse; @@ -39,6 +39,10 @@ public sealed class InputDispatcher private InputScope? _combatScope; private readonly HashSet _heldHoldChords = new(); private readonly HashSet _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. // _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. public event Action? Fired; - public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings) + private InputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) { _keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard)); _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.Game); // default top for normal play - _keyboard.KeyDown += OnKeyDown; - _keyboard.KeyUp += OnKeyUp; - _mouse.MouseDown += OnMouseDown; - _mouse.MouseUp += OnMouseUp; - _mouse.Scroll += OnScroll; + } + + /// + /// Production two-phase construction seam. The host publishes the owner + /// before so failed custom event accessors cannot + /// orphan a source subscription. + /// + 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 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); + } + } + + /// Immediate logical cutoff; physical detach remains retriable. + public void Deactivate() + { + Interlocked.Exchange(ref _active, 0); + _captureCallback = null; + _heldHoldChords.Clear(); + _automationHeldActions.Clear(); + } + + public void Dispose() + { + Interlocked.Exchange(ref _disposeRequested, 1); + Deactivate(); + List failures = DetachSources(); + if (failures.Count != 0) + throw new AggregateException( + "One or more input dispatcher source callbacks could not be detached.", + failures); } /// @@ -85,7 +165,9 @@ public sealed class InputDispatcher { if (action == InputAction.None || !Enum.IsDefined(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; Fired?.Invoke(action, ActivationType.Press); @@ -105,6 +187,9 @@ public sealed class InputDispatcher if (action == InputAction.None || !Enum.IsDefined(action)) throw new ArgumentOutOfRangeException(nameof(action)); + if (Volatile.Read(ref _active) == 0) + return false; + if (held) { if (_captureCallback is not null || _mouse.WantCaptureKeyboard) @@ -217,7 +302,7 @@ public sealed class InputDispatcher /// 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 // 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; @@ -341,7 +426,7 @@ public sealed class InputDispatcher /// 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. if (_heldHoldChords.Count == 0) return; @@ -363,6 +448,7 @@ public sealed class InputDispatcher 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 // precedence over both WantCaptureKeyboard gating AND normal // 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) { + if (Volatile.Read(ref _active) == 0) return; // Release fires regardless of WantCaptureKeyboard so we don't // strand a Hold subscriber in the "held" state if the UI captured // mid-press. @@ -444,6 +531,7 @@ public sealed class InputDispatcher private void OnMouseDown(MouseButton button, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; if (_mouse.WantCaptureMouse) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); @@ -478,6 +566,7 @@ public sealed class InputDispatcher private void OnMouseUp(MouseButton button, ModifierMask mods) { + if (Volatile.Read(ref _active) == 0) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); var release = FindActive(chord, ActivationType.Release); @@ -500,6 +589,7 @@ public sealed class InputDispatcher private void OnScroll(float delta) { + if (Volatile.Read(ref _active) == 0) return; if (_mouse.WantCaptureMouse) return; // K.1b: wheel ticks emit ScrollUp / ScrollDown depending on the // sign of the delta. Magnitude is dropped — the action is a @@ -526,4 +616,34 @@ public sealed class InputDispatcher MouseButton.Button5 => (Key)(-1005), _ => (Key)(-1000 - (int)button), }; + + private List DetachSources() + { + var failures = new List(); + 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; + } } diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs new file mode 100644 index 00000000..6517273b --- /dev/null +++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs @@ -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(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 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(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 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? Callback { get; private set; } + public void AddMouseMove(Action callback) + { + AddCalls++; + Callback = callback; + LiveEdges++; + if (RaiseDuringAdd) callback(new Vector2(50, 25)); + if (FailAdd) throw new InvalidOperationException("add"); + } + public void RemoveMouseMove(Action 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? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } + + 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; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs new file mode 100644 index 00000000..4884e3e9 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/DispatcherCameraInputSourceTests.cs @@ -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? KeyDown; + public event Action? 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? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? Scroll; +#pragma warning restore CS0067 + public bool IsHeld(MouseButton button) => false; + public bool WantCaptureMouse => false; + public bool WantCaptureKeyboard => false; + } +} diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs index ceee9f13..0cdcf87a 100644 --- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -112,12 +112,34 @@ public sealed class DispatcherMovementInputSourceTests Assert.Throws(() => 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) CreateDispatcher() { var keyboard = new FakeKeyboard(); 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 diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs index ee4e984c..5c591a18 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -18,7 +18,8 @@ public sealed class GameplayInputFrameControllerTests new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Hold)); - var dispatcher = new InputDispatcher(keyboard, mouseSource, bindings); + var dispatcher = InputDispatcher.CreateDetached(keyboard, mouseSource, bindings); + dispatcher.Attach(); keyboard.Press(Key.W); dispatcher.Fired += (_, activation) => { diff --git a/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs b/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs new file mode 100644 index 00000000..90086067 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/QuiescentInputContextTests.cs @@ -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(() => 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(context.Activate); + Assert.Throws( + () => keyboard.KeyDown += static (_, _, _) => { }); + Assert.Throws( + () => mouse.MouseDown += static (_, _) => { }); + Assert.Throws( + () => 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(); + _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? _connectionChanged; + public bool Disposed { get; private set; } + public nint Handle => 1; + public IReadOnlyList Gamepads { get; } = []; + public IReadOnlyList Joysticks { get; } = []; + public IReadOnlyList Keyboards { get; } = [keyboard]; + public IReadOnlyList Mice { get; } = [mouse]; + public IReadOnlyList OtherDevices { get; } = []; + + public event Action? 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? _down; + private Action? _up; + private Action? _char; + public bool RaiseDuringAdd { get; init; } + public string Name => "keyboard"; + public int Index => 0; + public bool IsConnected => true; + public IReadOnlyList SupportedKeys { get; } = []; + public string ClipboardText { get; set; } = string.Empty; + + public event Action? KeyDown + { + add => faults.Add(1, () => + { + _down += value; + if (RaiseDuringAdd) + _down?.Invoke(this, Key.A, 0); + }); + remove => faults.Remove(1, () => _down -= value); + } + public event Action? KeyUp + { + add => faults.Add(2, () => _up += value); + remove => faults.Remove(2, () => _up -= value); + } + public event Action? 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 _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>(nameof(IMouse.MouseDown))?.Invoke(_self, MouseButton.Left); + Get>(nameof(IMouse.MouseUp))?.Invoke(_self, MouseButton.Left); + Get>(nameof(IMouse.Click)) + ?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One); + Get>(nameof(IMouse.DoubleClick)) + ?.Invoke(_self, MouseButton.Left, System.Numerics.Vector2.One); + Get>(nameof(IMouse.MouseMove)) + ?.Invoke(_self, System.Numerics.Vector2.One); + Get>(nameof(IMouse.Scroll)) + ?.Invoke(_self, new ScrollWheel(1, 1)); + } + + private T? Get(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}."), + }; + } +} diff --git a/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs b/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs new file mode 100644 index 00000000..d341f6b4 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/SilkInputSourceLifetimeTests.cs @@ -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(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 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(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(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(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(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? _down; + private Action? _up; + public Action? Down => _down; + + public void AddKeyDown(Action callback) => Add(ref _down, callback); + public void AddKeyUp(Action callback) => Add(ref _up, callback); + public void RemoveKeyDown(Action callback) + { + RemoveCalls++; + if (FailRemoveDown) throw new InvalidOperationException("remove down"); + if (ReferenceEquals(_down, callback)) { _down = null; LiveEdges--; } + } + public void RemoveKeyUp(Action 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? slot, Action 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? _down; + private Action? _up; + private Action? _move; + private Action? _scroll; + + public void AddMouseDown(Action callback) => Add(ref _down, callback); + public void AddMouseUp(Action callback) => Add(ref _up, callback); + public void AddMouseMove(Action callback) => Add(ref _move, callback); + public void AddScroll(Action callback) => Add(ref _scroll, callback); + public void RemoveMouseDown(Action callback) => Remove(ref _down, callback); + public void RemoveMouseUp(Action callback) => Remove(ref _up, callback); + public void RemoveMouseMove(Action callback) + { + MoveRemoveCalls++; + if (FailRemoveMoveOnce) + { + FailRemoveMoveOnce = false; + throw new InvalidOperationException("remove move"); + } + Remove(ref _move, callback); + } + public void RemoveScroll(Action callback) => Remove(ref _scroll, callback); + public bool IsButtonPressed(MouseButton button) => false; + + public (Action Down, Action Up, Action Move, Action 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(ref Action? slot, Action callback) + { + AddCalls++; + slot = callback; + LiveEdges++; + if (AddCalls == FailAdd) throw new InvalidOperationException("add"); + } + + private void Remove(ref Action? slot, Action callback) + { + RemoveCalls++; + if (ReferenceEquals(slot, callback)) { slot = null; LiveEdges--; } + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs new file mode 100644 index 00000000..e26d845e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs @@ -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(); + 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(); + 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(); + 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 calls) : IFramebufferViewportTarget + { + public void ResizeViewport(int width, int height) => + calls.Add($"viewport:{width}x{height}"); + } + + private sealed class Camera(List calls) : IFramebufferCameraTarget + { + public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}"); + } + + private sealed class DevTools(List calls) : IFramebufferDevToolsTarget + { + public void ResetLayout(int width, int height) => + calls.Add($"devtools:{width}x{height}"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 91f0533b..ca339663 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -99,7 +99,13 @@ public sealed class GameWindowRenderLeafCompositionTests Assert.Contains("new AcDream.App.Rendering.PrivatePresentationRenderer(", source); Assert.Contains("new AcDream.App.Rendering.RenderFrameOrchestrator(", 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] diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 66428e24..54f050f9 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -54,14 +54,18 @@ public sealed class GameWindowSlice8BoundaryTests body, "_gl = GL.GetApi(_window!);", "_input = _window!.CreateInput();", - "_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);", - "_mouseSource = new AcDream.App.Input.SilkMouseSource(", - "_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(", + "_kbSource = AcDream.App.Input.SilkKeyboardSource.CreateDetached(", + "_kbSource.Attach();", + "_mouseSource = AcDream.App.Input.SilkMouseSource.CreateDetached(", + "_mouseSource.Attach();", + "_inputDispatcher = AcDream.UI.Abstractions.Input.InputDispatcher.CreateDetached(", + "_inputDispatcher.Attach();", "_movementInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);", "_inputDispatcher.Fired += OnInputAction;", - "mouse.MouseMove +=", "_cameraController = new CameraController(orbit, fly);", + "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", + "_cameraPointerInput.AttachRaw();", "_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);", "LoadAndApplyPersistedSettings();", "_uiHost = new AcDream.App.UI.UiHost(", @@ -74,6 +78,16 @@ public sealed class GameWindowSlice8BoundaryTests "_liveSessionHost = CreateLiveSessionHost();", "_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); string postStart = body[start..]; Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal); @@ -144,20 +158,28 @@ public sealed class GameWindowSlice8BoundaryTests } [Fact] - public void FramebufferResize_PreservesViewportCameraAndDevtoolsOrder() + public void FramebufferResize_IsOneTypedOwnerHandoff() { + string load = MethodBody( + "private void OnLoad()", + "private AcDream.App.Net.LiveSessionHost"); string body = MethodBody( "private void OnFramebufferResize(", "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( - body, - "if (newSize.X <= 0 || newSize.Y <= 0) return;", - "_gl?.Viewport(", - "_viewportAspect.Update(", - "_cameraController?.SetAspect(", - "_devToolsFramePresenter?.ResetLayout("); - Assert.DoesNotContain("_uiHost", body, StringComparison.Ordinal); + load, + "_framebufferResize.BindViewport(", + "_framebufferResize.BindCamera(", + "_framebufferResize.Resize(_window!.FramebufferSize);"); + Assert.DoesNotContain( + "_viewportAspect.Update(_window", + load, + StringComparison.Ordinal); } [Fact] @@ -167,7 +189,7 @@ public sealed class GameWindowSlice8BoundaryTests string update = Slice( source, "private void OnUpdate(double dt)", - "private void OnCameraModeChanged("); + "private void OnRender(double deltaSeconds)"); string render = Slice( source, "private void OnRender(double deltaSeconds)", @@ -189,7 +211,10 @@ public sealed class GameWindowSlice8BoundaryTests "_renderFrameOrchestrator!.Render(", "new AcDream.App.Rendering.RenderFrameInput("); Assert.DoesNotContain("FramebufferSize", render, StringComparison.Ordinal); - AssertAppearsInOrder(focus, "if (!focused)", "_gameplayInputFrame?.EndMouseLook();"); + Assert.Contains( + "=> _cameraPointerInput?.HandleFocusChanged(focused);", + focus, + StringComparison.Ordinal); AssertAppearsInOrder( close, "_hostQuiescence.StopAccepting();", @@ -212,7 +237,9 @@ public sealed class GameWindowSlice8BoundaryTests string[] stages = [ + "new ResourceShutdownStage(\"input callback deactivation\"", "new ResourceShutdownStage(\"session lifetime\"", + "new ResourceShutdownStage(\"input callback detach\"", "new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"live entities\"", @@ -236,6 +263,17 @@ public sealed class GameWindowSlice8BoundaryTests "binding.Dispose();", "if (!binding.IsDisposalComplete)", "_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( dispose, "CompleteShutdown();", diff --git a/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs b/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs new file mode 100644 index 00000000..1b29f4ae --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetainedUiInputBindingTests.cs @@ -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(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(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 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 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(mouse.Attach); + Assert.Throws(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? Down { get; private set; } + private Action? _up; + private Action? _move; + private Action? _scroll; + + public void AddMouseDown(Action callback) => Add(() => Down = callback); + public void AddMouseUp(Action callback) => Add(() => _up = callback); + public void AddMouseMove(Action callback) => Add(() => _move = callback); + public void AddScroll(Action callback) => Add(() => _scroll = callback); + public void RemoveMouseDown(Action callback) => Remove(() => Down = null); + public void RemoveMouseUp(Action callback) => Remove(() => _up = null); + public void RemoveMouseMove(Action callback) + { + MoveRemoveCalls++; + if (FailRemoveMoveOnce) + { + FailRemoveMoveOnce = false; + throw new InvalidOperationException("remove move"); + } + Remove(() => _move = null); + } + public void RemoveScroll(Action 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? Down { get; private set; } + private Action? _up; + private Action? _char; + + public void AddKeyDown(Action callback) => Add(() => Down = callback); + public void AddKeyUp(Action callback) => Add(() => _up = callback); + public void AddKeyChar(Action callback) => Add(() => _char = callback); + public void RemoveKeyDown(Action callback) => Remove(() => Down = null); + public void RemoveKeyUp(Action callback) + { + UpRemoveCalls++; + if (FailRemoveUpOnce) + { + FailRemoveUpOnce = false; + throw new InvalidOperationException("remove up"); + } + Remove(() => _up = null); + } + public void RemoveKeyChar(Action 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--; + } + } +} diff --git a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs index fdb9982c..eed4b3d1 100644 --- a/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveObjectFrameControllerTests.cs @@ -39,10 +39,11 @@ public sealed class LiveObjectFrameControllerTests private static SettingsVM CreateSettings() { - var dispatcher = new InputDispatcher( + var dispatcher = InputDispatcher.CreateDetached( new NullKeyboardSource(), new NullMouseSource(), new KeyBindings()); + dispatcher.Attach(); return new SettingsVM( new KeyBindings(), dispatcher, diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index b9e2aded..6f11f50d 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -409,13 +409,18 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.DoesNotContain("wantCaptureMouse: ()", source, StringComparison.Ordinal); - Assert.Equal(2, CountOccurrences( - source, - "_gameplayInputFrame?.EndMouseLook();")); - Assert.Contains( - "new(\"mouse capture\", () => _gameplayInputFrame?.EndMouseLook())", + Assert.DoesNotContain( + "_gameplayInputFrame?.EndMouseLook", source, 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( FindRepoRoot(), "src", @@ -438,8 +443,7 @@ public sealed class UpdateFrameOrchestratorTests AssertAppearsInOrder( source, "private void OnFocusChanged(bool focused)", - "if (!focused)", - "_gameplayInputFrame?.EndMouseLook();"); + "_cameraPointerInput?.HandleFocusChanged(focused);"); } [Fact] @@ -696,7 +700,7 @@ public sealed class UpdateFrameOrchestratorTests "private void OnUpdate(double dt)", "_frameProfiler.BeginStage(", "_updateFrameOrchestrator.Tick(", - "private void OnCameraModeChanged"); + "private void OnRender(double deltaSeconds)"); } [Fact] diff --git a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs index 0e124177..2769e3ed 100644 --- a/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs +++ b/tests/AcDream.Core.Tests/Input/DispatcherToMovementIntegrationTests.cs @@ -92,7 +92,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); @@ -122,7 +123,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); // Shift pressed alongside W — real keyboard delivers KeyDown(Shift, @@ -140,7 +142,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); kb.Press(Key.W); Assert.True(BuildInputFromDispatcher(dispatcher).Forward); @@ -188,7 +191,8 @@ public class DispatcherToMovementIntegrationTests var kb = new FakeKb(); var mouse = new FakeMouse(); var bindings = KeyBindings.AcdreamCurrentDefaults(); - var dispatcher = new InputDispatcher(kb, mouse, bindings); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings); + dispatcher.Attach(); var input = BuildInputFromDispatcher(dispatcher); Assert.False(input.Forward); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs index 9106846c..c10160a6 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs @@ -21,7 +21,8 @@ public class InputDispatcherCaptureTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); 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)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, kb, mouse, bindings, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs index f6de5daa..891cf049 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherDoubleClickTests.cs @@ -33,7 +33,8 @@ public class InputDispatcherDoubleClickTests bindings.Add(new Binding(lmbChord, InputAction.SelectDblLeft, ActivationType.DoubleClick)); 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)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, mouse, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index 163f763a..8dc6c274 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -19,7 +19,8 @@ public class InputDispatcherIsActionHeldTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); 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); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs new file mode 100644 index 00000000..3f272f5c --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherLifetimeTests.cs @@ -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(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 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(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(dispatcher.Attach); + dispatcher.Dispose(); + Assert.Equal(0, source.AddCalls); + Assert.Equal(0, source.LiveEdges); + } + + private sealed class ThrowingSources : IKeyboardSource, IMouseSource + { + private Action? _keyDown; + private Action? _keyUp; + private Action? _mouseDown; + private Action? _mouseUp; + private Action? _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? KeyDownDelegate => _keyDown; + + public event Action? 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? KeyUp + { + add => Add(ref _keyUp, value!); + remove => Remove(ref _keyUp, value!); + } + public event Action? MouseDown + { + add => Add(ref _mouseDown, value!); + remove => Remove(ref _mouseDown, value!); + } + public event Action? MouseUp + { + add => Add(ref _mouseUp, value!); + remove => Remove(ref _mouseUp, value!); + } +#pragma warning disable CS0067 + public event Action? MouseMove; +#pragma warning restore CS0067 + public event Action? 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(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(ref T? slot, T value) where T : Delegate + { + RemoveCalls++; + if (slot is null) return; + slot = (T?)Delegate.Remove(slot, value); + LiveEdges--; + } + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index c46d7a1d..6be58e1e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -13,7 +13,8 @@ public class InputDispatcherTests var kb = new FakeKeyboardSource(); var mouse = new FakeMouseSource(); 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)>(); dispatcher.Fired += (a, t) => fired.Add((a, t)); return (dispatcher, kb, mouse, bindings, fired); diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs index 6d51c515..7b420af9 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs @@ -28,7 +28,8 @@ public sealed class SettingsPanelTests var persisted = new KeyBindings(); persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); - var dispatcher = new InputDispatcher(kb, mouse, persisted); + var dispatcher = InputDispatcher.CreateDetached(kb, mouse, persisted); + dispatcher.Attach(); var vm = new SettingsVM( persisted, dispatcher, _ => { }, DisplaySettings.Default, _ => { }, diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs index c3e5b234..2c75537d 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs @@ -22,7 +22,8 @@ public sealed class SettingsVMTests persisted ??= MakeMinimalBindings(); var kb = new FakeKeyboardSource(); 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(); var savedDisplayHistory = new System.Collections.Generic.List(); var savedAudioHistory = new System.Collections.Generic.List();