refactor(input): own gameplay action routing

Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 12:43:05 +02:00
parent 8b8afeefa3
commit 4eae9b4f5a
25 changed files with 2608 additions and 418 deletions

View file

@ -115,7 +115,7 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; 7,477 tests pass / 5 skip. Release builds; 7,524 tests pass / 5 skip.
**Active prerequisite before new M4 subsystem work:** continue the **Active prerequisite before new M4 subsystem work:** continue the
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
@ -127,7 +127,7 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AE are also complete: dead host residue is removed, Slice 8 checkpoints AF are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `WorldEnvironmentController` solely owns the clock, DAT session controller. `WorldEnvironmentController` solely owns the clock, DAT
@ -136,11 +136,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state.
camera input/focus/scroll and physical framebuffer publication. Silk, camera input/focus/scroll and physical framebuffer publication. Silk,
dispatcher, retained-UI, and devtools device edges are reversible, dispatcher, retained-UI, and devtools device edges are reversible,
transactional, and terminal after disposal; live-session retirement precedes transactional, and terminal after disposal; live-session retirement precedes
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 physical callback removal. `GameplayInputActionRouter` now owns the sole
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The semantic action-priority
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and graph; typed owners route combat, diagnostics, and remaining commands, and the
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints FL—action retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines /
routing, settings, resource ownership, ordered composition, 198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign
baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate,
and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints GL—
settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -113,7 +113,7 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; 7,477 tests pass / 5 skip. Release builds; 7,524 tests pass / 5 skip.
**Active prerequisite before new M4 subsystem work:** continue the **Active prerequisite before new M4 subsystem work:** continue the
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21. behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
@ -125,7 +125,7 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods; owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership. the frame owners have no direct window or anonymous callback-bag ownership.
Slice 8 checkpoints AE are also complete: dead host residue is removed, Slice 8 checkpoints AF are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical `LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `WorldEnvironmentController` solely owns the clock, DAT session controller. `WorldEnvironmentController` solely owns the clock, DAT
@ -134,11 +134,14 @@ sky/day group, weather, synchronization, and AdminEnvirons state.
camera input/focus/scroll and physical framebuffer publication. Silk, camera input/focus/scroll and physical framebuffer publication. Silk,
dispatcher, retained-UI, and devtools device edges are reversible, dispatcher, retained-UI, and devtools device edges are reversible,
transactional, and terminal after disposal; live-session retirement precedes transactional, and terminal after disposal; live-session retirement precedes
physical callback removal. `GameWindow` is 4,266 raw lines / 194 fields / 65 physical callback removal. `GameplayInputActionRouter` now owns the sole
methods, down 11,457 lines (72.9%) from the 15,723-line campaign baseline. The semantic action-priority
7,477-test Release suite, 315.6-second lifecycle/reconnect gate, and graph; typed owners route combat, diagnostics, and remaining commands, and the
395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints FL—action retained-root item-drop edge is reversible. `GameWindow` is 4,057 raw lines /
routing, settings, resource ownership, ordered composition, 198 fields / 54 methods, down 11,666 lines (74.2%) from the 15,723-line campaign
baseline. The 7,524-test Release suite, 315.6-second lifecycle/reconnect gate,
and 395.2-second synchronized nine-stop soak pass. Slice 8 checkpoints GL—
settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active. retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153, `docs/architecture/code-structure.md`. **Carried:** #232, #153,

View file

@ -27,12 +27,13 @@ What does NOT go here:
## Current queue — 2026-07-22 ## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 are - **Active structural work:** `GameWindow` decomposition. Slices 17 are
complete; Slice 8 checkpoints AE now own native callbacks, live-session complete; Slice 8 checkpoints AF now own native callbacks, live-session
composition, world-environment state, reversible device input/raw pointer, composition, world-environment state, reversible device input/raw pointer,
and physical framebuffer publication outside the shell. physical framebuffer publication, gameplay action priority, typed commands,
and the retained-root item-drop edge outside the shell.
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
handoffs and the class is 4,266 lines / 194 fields / 65 methods. Remaining handoffs and the class is 4,057 lines / 198 fields / 54 methods. Remaining
Slice 8 action-routing/settings/composition/shutdown Slice 8 settings/resource/composition/shutdown
cleanup is active in cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md). [`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies. This is the behavior-preserving prerequisite before new M4 feature bodies.

View file

@ -625,11 +625,12 @@ The accepted host order above is preserved as TS-53; it is not overclaimed as
the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and
`memory/project_gamewindow_decomposition.md`. `memory/project_gamewindow_decomposition.md`.
The render callback currently owns the GPU frame boundary and draw graph. Slice `GameWindow.OnRender` likewise owns only one handoff to the shipped
7 extracts that graph without changing its order: per-resource frame begins and `RenderFrameOrchestrator`. Its frozen graph begins per-resource frames and
render-thread uploads; world/PView and its two shared-alpha scopes; portal and render-thread uploads; draws world/PView and its two shared-alpha scopes;
paperdoll private viewports; retained gameplay UI; ImGui devtools; screenshot; renders portal and paperdoll private viewports, retained gameplay UI, ImGui
then the GPU-flight close in `finally`. Both UI stacks coexist in the same frame. devtools, and screenshots; then closes the GPU-flight transaction in
`finally`. Both UI stacks coexist in the same frame.
--- ---
@ -828,10 +829,11 @@ Current movement/collision ownership:
routers; Slice 4 moved hydration, teardown, inbound authority, network routers; Slice 4 moved hydration, teardown, inbound authority, network
updates, and reusable motion-runtime policy over canonical updates, and reusable motion-runtime policy over canonical
`LiveEntityRuntime`. Slices 57 complete landblock presentation and the `LiveEntityRuntime`. Slices 57 complete landblock presentation and the
update/render frame graphs. Slice 8 checkpoints AE now own the native update/render frame graphs. Slice 8 checkpoints AF now own the native
callback shell, live-session composition, world environment, reversible callback shell, live-session composition, world environment, reversible
input/pointer lifetime, and physical framebuffer publication. Gameplay action input/pointer lifetime, physical framebuffer publication, the sole gameplay
routing is the next checkpoint. The reconciled ledger and sequence live in action-priority graph, typed commands, and the retained-root item-drop edge.
Two-phase runtime settings is the next checkpoint. The reconciled ledger and sequence live in
`docs/architecture/code-structure.md`. This decision does not reclassify the `docs/architecture/code-structure.md`. This decision does not reclassify the
remaining L.2 conformance work or Track LH. remaining L.2 conformance work or Track LH.

View file

@ -32,6 +32,8 @@ after Slice 5 closeout 8,811 lines / 247 fields / 153 method
after Slice 6 closeout 7,026 lines / 241 fields / 108 methods after Slice 6 closeout 7,026 lines / 241 fields / 108 methods
after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods after Slice 7 draw-frame cutover 4,666 lines / 196 fields / 70 methods
after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods after Slice 8 checkpoint D 4,330 lines / 192 fields / 67 methods
after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods
after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods
``` ```
`GameWindow` is the single object that: `GameWindow` is the single object that:
@ -690,15 +692,21 @@ slices. `OnClosing` delegates to the existing retryable shutdown transaction.
Silk callbacks become narrow calls into the input, update, render, resize, Silk callbacks become narrow calls into the input, update, render, resize,
focus, and shutdown owners. focus, and shutdown owners.
Checkpoint E is complete. `CameraPointerInputController` owns raw pointer, Checkpoints E and F are complete. `CameraPointerInputController` owns raw pointer,
camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController` camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
alone publishes physical framebuffer changes. All Silk, dispatcher, retained alone publishes physical framebuffer changes. All Silk, dispatcher, retained
UI, and optional devtools device edges are reversible, transactional, and UI, and optional devtools device edges are reversible, transactional, and
terminal after disposal. Logical cutoff precedes live-session retirement; terminal after disposal. Logical cutoff precedes live-session retirement;
physical detach follows it, so neither copied callbacks nor a failed event physical detach follows it, so neither copied callbacks nor a failed event
remove can interfere with graceful transport teardown. `GameWindow` is now remove can interfere with graceful transport teardown.
4,266 raw lines / 194 fields / 65 methods. Checkpoints FL remain active, with
gameplay action routing next. `GameplayInputActionRouter` is now the sole `InputDispatcher.Fired`
subscriber and preserves the accepted input-priority graph. Focused combat,
diagnostic, and general command owners replace the remaining window command
bodies. The retained-root item-drop edge has one reversible owner, while its
deferred toolbar targets become inert before session retirement. `GameWindow`
is now 4,057 raw lines / 198 fields / 54 methods. Checkpoints GL remain
active, with two-phase runtime settings next.
### 4.4 Exit criteria ### 4.4 Exit criteria

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 17 and Slice 8 checkpoints AE are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 17 and Slice 8 checkpoints AF are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
--- ---
@ -79,7 +79,7 @@ second synchronized soak pass with graceful exits, and the six stable PNG
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
process-residency gate's diagnostic blind spot; no tolerance was loosened. process-residency gate's diagnostic blind spot; no tolerance was loosened.
Final Slice 8 composition/shutdown cleanup is active. Checkpoints AE now own Final Slice 8 composition/shutdown cleanup is active. Checkpoints AF now own
the frozen shell boundary, all nine native callback edges, live-session the frozen shell boundary, all nine native callback edges, live-session
reset/selection/entry/route composition, and world clock/sky/day/weather/ reset/selection/entry/route composition, and world clock/sky/day/weather/
AdminEnvirons state. Checkpoint E adds reversible, transactional Silk, AdminEnvirons state. Checkpoint E adds reversible, transactional Silk,
@ -87,9 +87,12 @@ dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner;
and one physical framebuffer-size publisher. Logical callback cutoff and and one physical framebuffer-size publisher. Logical callback cutoff and
live-session retirement precede physical removal, so failed device detach live-session retirement precede physical removal, so failed device detach
cannot block graceful transport close. `GameWindow` is 4,266 lines / 194 cannot block graceful transport close. `GameWindow` is 4,266 lines / 194
fields / 65 methods; the Checkpoint E Release suite passes 7,477 tests / 5 fields / 65 methods. Checkpoint F adds the sole gameplay action-priority
skips. Gameplay routing, settings, startup/resource transfer, shutdown, soak, subscriber, typed combat/diagnostic/general commands, and a reversible
and closeout checkpoints FL remain. retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields /
54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips.
Settings, startup/resource transfer, shutdown, soak, and closeout checkpoints
GL remain.
This is a behavior-preserving structural program. Severe regressions still get This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4 root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -39,15 +39,17 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is
campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized 315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
nine-destination soak pass with graceful exits, and six stable PNG checkpoints nine-destination soak pass with graceful exits, and six stable PNG checkpoints
preserve Slice 6 presentation. Final Slice 8 checkpoints AE are complete: preserve Slice 6 presentation. Final Slice 8 checkpoints AF are complete:
native callbacks and live-session composition now have explicit retryable native callbacks and live-session composition now have explicit retryable
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky, owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
day group, weather, and AdminEnvirons state. Checkpoint E adds terminal, day group, weather, and AdminEnvirons state. Checkpoint E adds terminal,
transactional ownership for Silk/dispatcher/retained/devtools input, the raw transactional ownership for Silk/dispatcher/retained/devtools input, the raw
camera pointer, focus, and physical framebuffer size. Graceful live-session camera pointer, focus, and physical framebuffer size. Graceful live-session
retirement now precedes physical callback removal. `GameWindow` is 4,266 lines retirement now precedes physical callback removal. Checkpoint F adds the sole
/ 194 fields / 65 methods; 7,477 Release tests pass / 5 skip. Checkpoints FL gameplay action-priority owner, typed combat/diagnostic/general commands, and a
remain active. Issue #232 tracks process-residency variance in the soak without reversible retained-root item-drop binding. `GameWindow` is 4,057 lines / 198
fields / 54 methods; 7,524 Release tests pass / 5 skip. Checkpoints GL remain
active. Issue #232 tracks process-residency variance in the soak without
loosening its leak threshold. loosening its leak threshold.
Carried: Carried:

View file

@ -23,7 +23,7 @@ audit.
- [x] D — extract retail world-environment/day/weather behavior. - [x] D — extract retail world-environment/day/weather behavior.
- [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer - [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer
resize behind symmetric named subscriptions. resize behind symmetric named subscriptions.
- [ ] F — extract the sole gameplay input-action router plus focused combat and - [x] F — extract the sole gameplay input-action router plus focused combat and
diagnostic command owners. diagnostic command owners.
- [ ] G — extract two-phase persisted settings/display/quality ownership. - [ ] G — extract two-phase persisted settings/display/quality ownership.
- [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots - [ ] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
@ -527,6 +527,23 @@ connected lifecycle and visual gates.
- Make Combat, Camera, dispatcher, and retained-root subscriptions named, - Make Combat, Camera, dispatcher, and retained-root subscriptions named,
transactional, and symmetrically detached. transactional, and symmetrically detached.
Result: `GameplayInputActionRouter` is the sole gameplay subscriber to
`InputDispatcher.Fired` and preserves the frozen pointer → scroll → combat →
Press/DoubleClick gate → retained UI → selection → movement → command
priority. Focused command owners now route combat, diagnostics, window/player
mode, item-target mode, and Escape without calling feature bodies on
`GameWindow`. `RetainedUiGameplayBinding` owns the outside-UI item-drop edge;
the combat/diagnostic deferred slots release their targets before session
retirement, and the retained binding detaches transactionally afterward.
Named-retail review
also corrected `ToggleCombatMode @ 0x0056C8C0`: active combat short-circuits to
peace without an equipment lookup, while an incompatible Held object prints
the exact retail notice and sends no mode request. Three corrected-diff review
loops are clean; 54 focused App tests, 20 Core combat tests, the App suite
(3,155 pass / 3 intentional skips), the Release build, and the complete suite
(7,524 pass / 5 intentional skips) pass. `GameWindow` is 4,057 raw lines / 198
fields / 54 methods.
### G — two-phase runtime settings ### G — two-phase runtime settings
- Construct `RuntimeSettingsController` before Window.Create and source startup - Construct `RuntimeSettingsController` before Window.Create and source startup

View file

@ -3,6 +3,8 @@
## Sources ## Sources
- `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310` - `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`
- `ClientCombatSystem::SetCombatMode @ 0x0056BE30`
- `ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0`
- `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00` - `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00`
- `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0` - `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0`
- `SmartBox::HandleParentEvent @ 0x004535D0` - `SmartBox::HandleParentEvent @ 0x004535D0`
@ -52,10 +54,28 @@ GetDefaultCombatMode(showError):
return Magic return Magic
if showError == false: if showError == false:
print the retail cannot-use-held-item notice print "You can't enter combat mode while wielding the %s"
formatted with held.GetObjectName(NAME_APPROPRIATE)
return NonCombat return NonCombat
``` ```
`ToggleCombatMode` preserves that rejected result verbatim:
```text
ToggleCombatMode():
if current combat mode != NonCombat:
SetCombatMode(NonCombat, send = true)
return
requested = GetDefaultCombatMode(showError = false)
SetCombatMode(requested, send = true)
```
`SetCombatMode` immediately returns when `requested == current`. Therefore an
incompatible Held object prints the notice in `GetDefaultCombatMode`, returns
`NonCombat`, and produces no wire request or local mode transition. It is not
coerced to unarmed Melee.
`GetObjectAtLocation` walks the player's ordered inventory-placement list and `GetObjectAtLocation` walks the player's ordered inventory-placement list and
returns the first entry whose location intersects the requested mask (and whose returns the first entry whose location intersects the requested mask (and whose
priority intersects the requested priority when that priority is non-zero). priority intersects the requested priority when that priority is non-zero).

View file

@ -3,9 +3,9 @@
## Current state ## Current state
The behavior-preserving App ownership campaign is complete through Slice 7 and The behavior-preserving App ownership campaign is complete through Slice 7 and
Slice 8 checkpoints AE. `GameWindow.cs` moved from the 2026-07-21 baseline of Slice 8 checkpoints AF. `GameWindow.cs` moved from the 2026-07-21 baseline of
15,723 lines / 278 fields / 205 methods to 4,266 lines / 194 fields / 65 15,723 lines / 278 fields / 205 methods to 4,057 lines / 198 fields / 54
methods: 11,457 lines (72.9%) were removed without changing accepted gameplay methods: 11,666 lines (74.2%) were removed without changing accepted gameplay
or rendering behavior. or rendering behavior.
| Slice | Ownership moved out | Closeout size | | Slice | Ownership moved out | Closeout size |
@ -18,6 +18,7 @@ or rendering behavior.
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 | | 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 | | 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
| 8 AE | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 | | 8 AE | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 |
| 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 |
Slice 8 is an ordered checkpoint campaign rather than another feature-body Slice 8 is an ordered checkpoint campaign rather than another feature-body
move. A froze/pruned the shell boundary; B gave all nine native window callback move. A froze/pruned the shell boundary; B gave all nine native window callback
@ -43,6 +44,17 @@ run only after session retirement. This prevents both copied callback reentry
and a failed event accessor from blocking F653/transport teardown. The carried and a failed event accessor from blocking F653/transport teardown. The carried
non-retail post-filter camera scalars are explicitly registered as TS-56. non-retail post-filter camera scalars are explicitly registered as TS-56.
F adds `GameplayInputActionRouter` as the sole `InputDispatcher.Fired`
subscriber while preserving the frozen action priority. Typed command owners
replace the remaining combat, diagnostic, window/player mode, item-target,
and Escape bodies. `RetainedUiGameplayBinding` owns the outside-UI item-drop
event. The two deferred command slots release their targets before session
retirement; the retained binding alone detaches its physical edge
transactionally afterward. Retail
review also corrected the carried default-combat mismatch: active combat
short-circuits directly to peace, and an incompatible Held object prints the
exact retail notice without a wire request or local mode mutation.
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`, Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`. `c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`, Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
@ -99,6 +111,10 @@ review rule live in `docs/architecture/code-structure.md`.
## Accepted verification ## Accepted verification
- Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment
skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat
conformance tests pass, and all three independent corrected-diff reviews are
clean.
- Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment - 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 skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent
corrected-diff reviews are clean. corrected-diff reviews are clean.
@ -131,8 +147,8 @@ review rule live in `docs/architecture/code-structure.md`.
## Next work ## Next work
Slice 8 checkpoints FL group the remaining action routing, settings, resource Slice 8 checkpoints GL group the remaining settings, resource ownership,
ownership, startup, and shutdown wiring and reduce Silk callbacks to startup, and shutdown wiring and reduce Silk callbacks to
narrow calls into input, update, render, resize, focus, and shutdown owners. narrow calls into input, update, render, resize, focus, and shutdown owners.
It removes leftover feature state only where Slices 17 already established a It removes leftover feature state only where Slices 17 already established a
canonical owner; it does not redesign gameplay, rendering, or resource lifetime. canonical owner; it does not redesign gameplay, rendering, or resource lifetime.

View file

@ -0,0 +1,214 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Combat;
internal interface ILiveCombatModeAuthority
{
bool IsInWorld { get; }
void SendChangeCombatMode(CombatMode mode);
}
internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session)
: ILiveCombatModeAuthority
{
private readonly LiveSessionHost _session = session
?? throw new ArgumentNullException(nameof(session));
public bool IsInWorld =>
_session.IsInWorld && _session.CurrentSession is not null;
public void SendChangeCombatMode(CombatMode mode)
{
if (!IsInWorld)
{
throw new InvalidOperationException(
"A combat-mode request requires an active in-world session.");
}
_session.CurrentSession!.SendChangeCombatMode(mode);
}
}
internal interface ICombatEquipmentSource
{
IReadOnlyList<ClientObject> GetOrderedEquipment();
}
internal sealed class LocalPlayerCombatEquipmentSource(
ClientObjectTable objects,
ILocalPlayerIdentitySource identity) : ICombatEquipmentSource
{
private readonly ClientObjectTable _objects = objects
?? throw new ArgumentNullException(nameof(objects));
private readonly ILocalPlayerIdentitySource _identity = identity
?? throw new ArgumentNullException(nameof(identity));
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_objects.GetEquippedBy(_identity.ServerGuid);
}
internal interface IExplicitCombatModeIntentSink
{
void NotifyExplicitCombatModeRequest();
}
internal sealed class ItemInteractionCombatModeIntentSink(
ItemInteractionController items) : IExplicitCombatModeIntentSink
{
private readonly ItemInteractionController _items = items
?? throw new ArgumentNullException(nameof(items));
public void NotifyExplicitCombatModeRequest() =>
_items.NotifyExplicitCombatModeRequest();
}
internal interface ILiveCombatModeCommand
{
void Toggle();
}
/// <summary>
/// Focused deferred edge shared by the early-created retained toolbar and the
/// later-created gameplay router. It owns no combat state and forwards to one
/// window-lifetime command owner after session composition is complete.
/// </summary>
internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
{
private readonly object _gate = new();
private ILiveCombatModeCommand? _target;
private bool _deactivated;
public void Bind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Live combat-mode commands are already bound.");
}
_target = target;
}
}
public void Unbind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public void Toggle()
{
lock (_gate)
{
if (!_deactivated)
_target?.Toggle();
}
}
}
/// <summary>
/// Owns the input-facing combat-mode command. The choice itself remains the
/// retail port in <see cref="CombatInputPlanner"/>:
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
/// authority; the local state update preserves the accepted immediate toolbar
/// response while the server confirmation travels through the normal route.
/// </summary>
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
{
private readonly ILiveCombatModeAuthority _authority;
private readonly ICombatEquipmentSource _equipment;
private readonly CombatState _combat;
private readonly IExplicitCombatModeIntentSink _itemIntent;
private readonly Action<string> _log;
private readonly Action<string>? _toast;
private readonly Action<string> _systemMessage;
public LiveCombatModeCommandController(
ILiveCombatModeAuthority authority,
ICombatEquipmentSource equipment,
CombatState combat,
IExplicitCombatModeIntentSink itemIntent,
Action<string>? log = null,
Action<string>? toast = null,
Action<string>? systemMessage = null)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
_log = log ?? (_ => { });
_toast = toast;
_systemMessage = systemMessage ?? (_ => { });
}
public void Toggle()
{
if (!_authority.IsInWorld)
return;
// Every explicit user combat request supersedes an in-flight auto-wield
// settlement, including a request retail rejects before SetCombatMode.
_itemIntent.NotifyExplicitCombatModeRequest();
CombatMode currentMode = _combat.CurrentMode;
CombatMode nextMode;
if (currentMode != CombatMode.NonCombat)
{
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
// never queries default equipment on this branch.
nextMode = CombatMode.NonCombat;
}
else
{
DefaultCombatModeDecision defaultDecision =
CombatInputPlanner.GetDefaultCombatModeDecision(
_equipment.GetOrderedEquipment());
nextMode = CombatInputPlanner.ToggleMode(
currentMode,
defaultDecision.Mode);
// GetDefaultCombatMode prints this notice before SetCombatMode.
// SetCombatMode then sees NonCombat == NonCombat and does no work.
if (defaultDecision.IncompatibleHeldItem is { } held)
{
string notice =
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
_log($"combat: {notice}");
_systemMessage(notice);
return;
}
}
// Preserve the accepted command order: explicit intent was published
// above, then send the request and publish local presentation.
_authority.SendChangeCombatMode(nextMode);
_combat.SetCombatMode(nextMode);
string message = $"Combat mode {nextMode}";
_log($"combat: {message}");
_toast?.Invoke(message);
}
}

View file

@ -0,0 +1,340 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Diagnostics;
internal interface IRuntimeDiagnosticCommands
{
bool Handle(InputAction action);
void CycleTimeOfDay();
void CycleWeather();
void ToggleCollisionWireframes();
}
/// <summary>
/// Focused deferred edge used by the early-created developer panel and the
/// later-created gameplay router. It owns no diagnostic state and binds one
/// window-lifetime command owner exactly once.
/// </summary>
internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
{
private readonly object _gate = new();
private IRuntimeDiagnosticCommands? _target;
private bool _deactivated;
public void Bind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Runtime diagnostic commands are already bound.");
}
_target = target;
}
}
public void Unbind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public bool Handle(InputAction action)
{
lock (_gate)
{
if (!_deactivated && _target is { } target)
return target.Handle(action);
}
return RuntimeDiagnosticCommandController.IsDiagnosticAction(action);
}
public void CycleTimeOfDay()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleTimeOfDay();
}
}
public void CycleWeather()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleWeather();
}
}
public void ToggleCollisionWireframes()
{
lock (_gate)
{
if (!_deactivated)
_target?.ToggleCollisionWireframes();
}
}
}
internal interface INearbyWorldDiagnosticSource
{
Vector3 ObserverPosition { get; }
int LiveCenterX { get; }
int LiveCenterY { get; }
int TotalShadowObjects { get; }
IReadOnlyList<WorldEntity> WorldEntities { get; }
IEnumerable<ShadowEntry> ShadowEntries { get; }
}
/// <summary>
/// Read-only view over canonical runtime owners. It does not cache the current
/// player, camera, entity list, origin, or shadow registry.
/// </summary>
internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource
{
private readonly ILocalPlayerModeSource _mode;
private readonly ILocalPlayerControllerSource _player;
private readonly CameraController _camera;
private readonly LiveWorldOriginState _origin;
private readonly GpuWorldState _world;
private readonly PhysicsEngine _physics;
public RuntimeNearbyWorldDiagnosticSource(
ILocalPlayerModeSource mode,
ILocalPlayerControllerSource player,
CameraController camera,
LiveWorldOriginState origin,
GpuWorldState world,
PhysicsEngine physics)
{
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_world = world ?? throw new ArgumentNullException(nameof(world));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
public Vector3 ObserverPosition
{
get
{
if (_mode.IsPlayerMode && _player.Controller is { } player)
return player.Position;
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView);
return new Vector3(
inverseView.M41,
inverseView.M42,
inverseView.M43);
}
}
public int LiveCenterX => _origin.CenterX;
public int LiveCenterY => _origin.CenterY;
public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered;
public IReadOnlyList<WorldEntity> WorldEntities => _world.Entities;
public IEnumerable<ShadowEntry> ShadowEntries =>
_physics.ShadowObjects.AllEntriesForDebug();
}
internal sealed class NearbyWorldDiagnosticDumper
{
private const float NearbyDistance = 15f;
private readonly INearbyWorldDiagnosticSource _source;
private readonly Action<string> _log;
public NearbyWorldDiagnosticDumper(
INearbyWorldDiagnosticSource source,
Action<string>? log = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_log = log ?? (_ => { });
}
public void Dump()
{
Vector3 position = _source.ObserverPosition;
int centerX = _source.LiveCenterX;
int centerY = _source.LiveCenterY;
int landblockX = centerX + (int)MathF.Floor(position.X / 192f);
int landblockY = centerY + (int)MathF.Floor(position.Y / 192f);
_log(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" +
$" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " +
$"local=({position.X - (landblockX - centerX) * 192f:F2}," +
$"{position.Y - (landblockY - centerY) * 192f:F2})\n" +
$" total shadow objects: {_source.TotalShadowObjects}");
List<WorldEntity> visibleNearby = _source.WorldEntities
.Where(entity => HorizontalDistanceSquared(entity.Position, position)
< NearbyDistance * NearbyDistance)
.OrderBy(entity => (entity.Position - position).Length())
.ToList();
_log($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (WorldEntity entity in visibleNearby.Take(12))
{
float distance = (entity.Position - position).Length();
_log(
$" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " +
$"dist={distance:F2} scale={entity.Scale:F2}");
}
List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries
.Select(entry =>
{
float dx = entry.Position.X - position.X;
float dy = entry.Position.Y - position.Y;
return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy));
})
.Where(item => item.Distance < NearbyDistance)
.OrderBy(item => item.Distance)
.ToList();
_log($" SHADOW objects within 15m: {shadows.Count}");
foreach ((ShadowEntry entry, float distance) in shadows.Take(12))
{
_log(
$" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " +
$"r={entry.Radius:F2} h={entry.CylHeight:F2} " +
$"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " +
$"dist={distance:F2}");
}
}
private static float HorizontalDistanceSquared(Vector3 left, Vector3 right)
{
float dx = left.X - right.X;
float dy = left.Y - right.Y;
return dx * dx + dy * dy;
}
}
/// <summary>
/// Routes developer-only semantic actions while leaving every mutable value in
/// its canonical owner. No diagnostic command owns a second clock, weather,
/// camera-sensitivity, collision, entity, or shadow state.
/// </summary>
internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands
{
private readonly WorldEnvironmentController _environment;
private readonly WorldSceneDebugState _sceneDebug;
private readonly IPointerSensitivityCommands? _pointer;
private readonly NearbyWorldDiagnosticDumper _nearby;
private readonly Action<string>? _toast;
public RuntimeDiagnosticCommandController(
WorldEnvironmentController environment,
WorldSceneDebugState sceneDebug,
IPointerSensitivityCommands? pointer,
NearbyWorldDiagnosticDumper nearby,
Action<string>? toast = null)
{
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug));
_pointer = pointer;
_nearby = nearby ?? throw new ArgumentNullException(nameof(nearby));
_toast = toast;
}
public bool Handle(InputAction action)
{
switch (action)
{
case InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWireframes();
return true;
case InputAction.AcdreamDumpNearby:
_nearby.Dump();
return true;
case InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
return true;
case InputAction.AcdreamSensitivityDown:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1f / 1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamSensitivityUp:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamCycleWeather:
CycleWeather();
return true;
default:
return false;
}
}
public void CycleTimeOfDay()
{
string message = _environment.CycleTimeOfDay();
_toast?.Invoke(message);
}
public void CycleWeather()
{
string message = _environment.CycleWeather();
_toast?.Invoke(message);
}
public void ToggleCollisionWireframes()
{
bool visible = _sceneDebug.ToggleCollisionWireframes();
_toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
internal static bool IsDiagnosticAction(InputAction action) => action is
InputAction.AcdreamToggleCollisionWires
or InputAction.AcdreamDumpNearby
or InputAction.AcdreamCycleTimeOfDay
or InputAction.AcdreamSensitivityDown
or InputAction.AcdreamSensitivityUp
or InputAction.AcdreamCycleWeather;
}

View file

@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
} }
} }
internal interface IPointerSensitivityCommands
{
string AdjustSensitivity(float factor);
}
/// <summary> /// <summary>
/// Owns raw pointer subscriptions and the camera-facing pointer policy that /// Owns raw pointer subscriptions and the camera-facing pointer policy that
/// previously lived in GameWindow. Physical event removal is retriable; logical /// previously lived in GameWindow. Physical event removal is retriable; logical
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot /// deactivation is immediate, so a copied or stubborn Silk delegate cannot
/// re-enter gameplay while shutdown is closing the live session. /// re-enter gameplay while shutdown is closing the live session.
/// </summary> /// </summary>
internal sealed class CameraPointerInputController : IDisposable internal sealed class CameraPointerInputController
: IDisposable, IPointerSensitivityCommands
{ {
private readonly IReadOnlyList<IRawPointerSurface> _surfaces; private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
private readonly IPointerCursorModeTarget _cursor; private readonly IPointerCursorModeTarget _cursor;

View file

@ -0,0 +1,316 @@
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IGameplayInputActionSurface
{
void AddFired(Action<InputAction, ActivationType> callback);
void RemoveFired(Action<InputAction, ActivationType> callback);
void SetCombatScope(InputScope? scope);
}
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
: IGameplayInputActionSurface
{
private readonly InputDispatcher _dispatcher = dispatcher
?? throw new ArgumentNullException(nameof(dispatcher));
public void AddFired(Action<InputAction, ActivationType> callback) =>
_dispatcher.Fired += callback;
public void RemoveFired(Action<InputAction, ActivationType> callback) =>
_dispatcher.Fired -= callback;
public void SetCombatScope(InputScope? scope) =>
_dispatcher.SetCombatScope(scope);
}
internal interface ICombatModeEventSurface
{
CombatMode CurrentMode { get; }
void AddChanged(Action<CombatMode> callback);
void RemoveChanged(Action<CombatMode> callback);
}
internal sealed class CombatStateModeEventSurface(CombatState combat)
: ICombatModeEventSurface
{
private readonly CombatState _combat = combat
?? throw new ArgumentNullException(nameof(combat));
public CombatMode CurrentMode => _combat.CurrentMode;
public void AddChanged(Action<CombatMode> callback) =>
_combat.CombatModeChanged += callback;
public void RemoveChanged(Action<CombatMode> callback) =>
_combat.CombatModeChanged -= callback;
}
internal interface IGameplayInputPriorityTargets
{
bool HandlePointerAction(InputAction action, ActivationType activation);
void HandleScroll(InputAction action);
bool HandleCombatAction(InputAction action, ActivationType activation);
bool HandleRetainedUiAction(InputAction action);
bool HandleSelectionAction(InputAction action);
bool HandlePressedMovementAction(InputAction action);
void HandleCommand(InputAction action);
}
/// <summary>
/// Typed adapter over canonical gameplay owners. Optional presentation owners
/// remain no-ops when their stack is disabled; no state is mirrored here.
/// </summary>
internal sealed class RuntimeGameplayInputPriorityTargets
: IGameplayInputPriorityTargets
{
private readonly GameplayInputFrameController _frame;
private readonly CameraPointerInputController _pointer;
private readonly RetailUiRuntime? _retainedUi;
private readonly SelectionInteractionController? _selection;
private readonly IGameplayInputCommandTarget _commands;
public RuntimeGameplayInputPriorityTargets(
GameplayInputFrameController frame,
CameraPointerInputController pointer,
RetailUiRuntime? retainedUi,
SelectionInteractionController? selection,
IGameplayInputCommandTarget commands)
{
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_retainedUi = retainedUi;
_selection = selection;
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
}
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_frame.HandlePointerAction(action, activation);
public void HandleScroll(InputAction action) =>
_pointer.HandleScroll(action);
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
_frame.HandleCombatAction(action, activation);
public bool HandleRetainedUiAction(InputAction action) =>
_retainedUi?.HandleInputAction(action) == true;
public bool HandleSelectionAction(InputAction action) =>
_selection?.HandleInputAction(action) == true;
public bool HandlePressedMovementAction(InputAction action) =>
_frame.HandlePressedMovementAction(action);
public void HandleCommand(InputAction action) =>
_commands.Handle(action);
}
/// <summary>
/// Sole gameplay subscriber to <see cref="InputDispatcher.Fired"/>. The route
/// preserves the accepted priority exactly: pointer, scroll, combat,
/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands.
/// Subscription ownership is reversible and callbacks become inert before the
/// live-session shutdown barrier.
/// </summary>
internal sealed class GameplayInputActionRouter : IDisposable
{
private readonly IGameplayInputActionSurface _actions;
private readonly ICombatModeEventSurface _combat;
private readonly IGameplayInputPriorityTargets _targets;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<string> _log;
private readonly Action<InputAction, ActivationType> _fired;
private readonly Action<CombatMode> _combatModeChanged;
private readonly bool[] _attached = new bool[2];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public GameplayInputActionRouter(
IGameplayInputActionSurface actions,
ICombatModeEventSurface combat,
IGameplayInputPriorityTargets targets,
HostQuiescenceGate quiescence,
Action<string>? log = null)
{
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_log = log ?? (_ => { });
_fired = OnFired;
_combatModeChanged = OnCombatModeChanged;
}
public static GameplayInputActionRouter Create(
InputDispatcher dispatcher,
CombatState combat,
IGameplayInputPriorityTargets targets,
HostQuiescenceGate quiescence,
Action<string>? log = null) =>
new(
new DispatcherGameplayInputActionSurface(dispatcher),
new CombatStateModeEventSurface(combat),
targets,
quiescence,
log);
public bool IsDisposalComplete =>
_attached.All(static attached => !attached);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
{
throw new InvalidOperationException(
"Gameplay action attachment has already started.");
}
_attachStarted = true;
try
{
// Mark each edge before invoking its custom accessor. An accessor
// may perform the add and then throw; rollback must still remove it.
_attached[0] = true;
_actions.AddFired(_fired);
_attached[1] = true;
_combat.AddChanged(_combatModeChanged);
Volatile.Write(ref _active, 1);
SetCombatScope(_combat.CurrentMode);
}
catch (Exception attachError)
{
Deactivate();
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Gameplay action registration and rollback both failed.",
new InvalidOperationException(
"Gameplay action registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Gameplay action registration failed and was rolled back.",
attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnFired(InputAction action, ActivationType activation) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
Route(action, activation);
});
private void OnCombatModeChanged(CombatMode mode) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
SetCombatScope(mode);
});
private void Route(InputAction action, ActivationType activation)
{
_log($"[input] {action} {activation}");
if (_targets.HandlePointerAction(action, activation))
return;
if (action is InputAction.ScrollUp or InputAction.ScrollDown)
{
if (activation == ActivationType.Press)
_targets.HandleScroll(action);
return;
}
if (_targets.HandleCombatAction(action, activation))
return;
if (activation is not ActivationType.Press
and not ActivationType.DoubleClick)
{
return;
}
if (_targets.HandleRetainedUiAction(action))
return;
if (_targets.HandleSelectionAction(action))
return;
if (_targets.HandlePressedMovementAction(action))
return;
_targets.HandleCommand(action);
}
private void SetCombatScope(CombatMode mode) =>
_actions.SetCombatScope(mode switch
{
CombatMode.Melee => InputScope.MeleeCombat,
CombatMode.Missile => InputScope.MissileCombat,
CombatMode.Magic => InputScope.MagicCombat,
_ => null,
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("gameplay action callbacks",
[
new("combat mode", () => Remove(1)),
new("dispatcher fired", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index])
return;
switch (index)
{
case 0:
_actions.RemoveFired(_fired);
break;
case 1:
_combat.RemoveChanged(_combatModeChanged);
break;
default:
throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
}

View file

@ -0,0 +1,210 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IRetainedGameplayWindowCommands
{
void ToggleInventory();
}
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
: IRetainedGameplayWindowCommands
{
private readonly RetailUiRuntime? _runtime = runtime;
public void ToggleInventory() =>
_runtime?.ToggleWindow(WindowNames.Inventory);
}
internal interface IDevToolsGameplayCommands
{
void ToggleDebugPanel();
void FocusChatInput();
void ToggleSettingsPanel();
}
internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter)
: IDevToolsGameplayCommands
{
private readonly DevToolsFramePresenter? _presenter = presenter;
public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel();
public void FocusChatInput() => _presenter?.FocusChatInput();
public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel();
}
internal interface IPlayerModeGameplayCommands
{
bool IsPlayerMode { get; }
void ToggleFlyOrChase();
void TogglePlayerMode();
void ExitPlayerMode();
}
internal sealed class PlayerModeGameplayCommands(
ILocalPlayerModeSource mode,
PlayerModeController controller) : IPlayerModeGameplayCommands
{
private readonly ILocalPlayerModeSource _mode = mode
?? throw new ArgumentNullException(nameof(mode));
private readonly PlayerModeController _controller = controller
?? throw new ArgumentNullException(nameof(controller));
public bool IsPlayerMode => _mode.IsPlayerMode;
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
public void TogglePlayerMode() => _controller.Toggle();
public void ExitPlayerMode() => _controller.Exit();
}
internal interface IItemTargetModeCommands
{
bool IsAnyTargetModeActive { get; }
void CancelTargetMode();
}
internal sealed class ItemTargetModeCommands(ItemInteractionController items)
: IItemTargetModeCommands
{
private readonly ItemInteractionController _items = items
?? throw new ArgumentNullException(nameof(items));
public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive;
public void CancelTargetMode() => _items.CancelTargetMode();
}
internal interface IGameplayCameraModeCommands
{
bool IsFlyMode { get; }
void ExitFlyMode();
}
internal sealed class GameplayCameraModeCommands(CameraController camera)
: IGameplayCameraModeCommands
{
private readonly CameraController _camera = camera
?? throw new ArgumentNullException(nameof(camera));
public bool IsFlyMode => _camera.IsFlyMode;
public void ExitFlyMode() => _camera.ToggleFly();
}
internal interface IGameplayWindowCommands
{
void Close();
}
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
{
private readonly Action _close = close
?? throw new ArgumentNullException(nameof(close));
public void Close() => _close();
}
internal interface IGameplayInputCommandTarget
{
bool Handle(InputAction action);
}
/// <summary>
/// Owns the final command tier of the frozen gameplay-action priority graph.
/// It coordinates canonical owners but stores no UI, camera, player, combat,
/// item-target, or diagnostic state of its own.
/// </summary>
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
{
private readonly IRetainedGameplayWindowCommands _retained;
private readonly IDevToolsGameplayCommands _devTools;
private readonly IRuntimeDiagnosticCommands _diagnostics;
private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode;
private readonly IGameplayCameraModeCommands _camera;
private readonly ILiveCombatModeCommand _combat;
private readonly IGameplayWindowCommands _window;
public GameplayInputCommandController(
IRetainedGameplayWindowCommands retained,
IDevToolsGameplayCommands devTools,
IRuntimeDiagnosticCommands diagnostics,
IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode,
IGameplayCameraModeCommands camera,
ILiveCombatModeCommand combat,
IGameplayWindowCommands window)
{
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_window = window ?? throw new ArgumentNullException(nameof(window));
}
public bool Handle(InputAction action)
{
if (_diagnostics.Handle(action))
return true;
switch (action)
{
case InputAction.ToggleInventoryPanel:
_retained.ToggleInventory();
return true;
case InputAction.AcdreamToggleDebugPanel:
_devTools.ToggleDebugPanel();
return true;
case InputAction.AcdreamToggleFlyMode:
_playerMode.ToggleFlyOrChase();
return true;
case InputAction.AcdreamTogglePlayerMode:
_playerMode.TogglePlayerMode();
return true;
case InputAction.ToggleChatEntry:
_devTools.FocusChatInput();
return true;
case InputAction.ToggleOptionsPanel:
_devTools.ToggleSettingsPanel();
return true;
case InputAction.CombatToggleCombat:
_combat.Toggle();
return true;
case InputAction.EscapeKey:
HandleEscape();
return true;
default:
return false;
}
}
private void HandleEscape()
{
if (_targetMode.IsAnyTargetModeActive)
_targetMode.CancelTargetMode();
else if (_camera.IsFlyMode)
_camera.ExitFlyMode();
else if (_playerMode.IsPlayerMode)
_playerMode.ExitPlayerMode();
else
_window.Close();
}
}

View file

@ -0,0 +1,139 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Input;
internal interface IRetainedUiDragReleaseSurface
{
void AddReleasedOutside(Action<object, int, int> callback);
void RemoveReleasedOutside(Action<object, int, int> callback);
}
internal sealed class UiRootDragReleaseSurface(UiRoot root)
: IRetainedUiDragReleaseSurface
{
private readonly UiRoot _root = root
?? throw new ArgumentNullException(nameof(root));
public void AddReleasedOutside(Action<object, int, int> callback) =>
_root.DragReleasedOutsideUi += callback;
public void RemoveReleasedOutside(Action<object, int, int> callback) =>
_root.DragReleasedOutsideUi -= callback;
}
/// <summary>
/// Owns the retained-root gameplay edge used when an item drag is released in
/// the world. Logical deactivation precedes live-session teardown and physical
/// removal remains retriable if a custom event accessor fails.
/// </summary>
internal sealed class RetainedUiGameplayBinding : IDisposable
{
private readonly IRetainedUiDragReleaseSurface _surface;
private readonly Action<ItemDragPayload, int, int> _placeDraggedItem;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<object, int, int> _releasedOutside;
private ResourceShutdownTransaction? _detach;
private bool _attached;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public RetainedUiGameplayBinding(
IRetainedUiDragReleaseSurface surface,
Action<ItemDragPayload, int, int> placeDraggedItem,
HostQuiescenceGate quiescence)
{
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
_placeDraggedItem = placeDraggedItem
?? throw new ArgumentNullException(nameof(placeDraggedItem));
_quiescence = quiescence
?? throw new ArgumentNullException(nameof(quiescence));
_releasedOutside = OnReleasedOutside;
}
public static RetainedUiGameplayBinding Create(
UiRoot root,
Action<ItemDragPayload, int, int> placeDraggedItem,
HostQuiescenceGate quiescence) =>
new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence);
public bool IsDisposalComplete => !_attached;
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
{
throw new InvalidOperationException(
"Retained gameplay attachment has already started.");
}
_attachStarted = true;
try
{
// Publish before the custom accessor: it may add and then throw.
_attached = true;
_surface.AddReleasedOutside(_releasedOutside);
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Retained gameplay registration and rollback both failed.",
new InvalidOperationException(
"Retained gameplay registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Retained gameplay registration failed and was rolled back.",
attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnReleasedOutside(object payload, int x, int y) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0
&& payload is ItemDragPayload item)
{
_placeDraggedItem(item, x, y);
}
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("retained gameplay callbacks",
[
new("drag released outside UI", Remove),
]));
private void Remove()
{
if (!_attached)
return;
_surface.RemoveReleasedOutside(_releasedOutside);
_attached = false;
}
}

View file

@ -447,12 +447,9 @@ public sealed class GameWindow : IDisposable
// the bool here. // the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry; private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// Phase K.1b — single input path. Every keyboard/mouse-button reaction // Phase K.1b / Slice 8 F — one semantic input path. Transitional actions
// flows through InputDispatcher.Fired (see OnInputAction below) or // flow through GameplayInputActionRouter; held movement is polled through
// IsActionHeld (per-frame polling for movement). The legacy direct // InputDispatcher. Raw axis motion belongs to CameraPointerInputController.
// kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only
// mouse.MouseMove survives as a direct handler because mouse-delta is
// axis input, not chord input.
private AcDream.App.Input.SilkKeyboardSource? _kbSource; private AcDream.App.Input.SilkKeyboardSource? _kbSource;
private AcDream.App.Input.SilkMouseSource? _mouseSource; private AcDream.App.Input.SilkMouseSource? _mouseSource;
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher; private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
@ -462,6 +459,12 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new(); private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new();
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor; private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame; private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
private AcDream.App.Input.GameplayInputActionRouter? _gameplayInputActions;
private AcDream.App.Input.RetainedUiGameplayBinding? _retainedUiGameplayBinding;
private readonly AcDream.App.Diagnostics.RuntimeDiagnosticCommandSlot
_runtimeDiagnosticCommands = new();
private readonly AcDream.App.Combat.LiveCombatModeCommandSlot
_liveCombatModeCommands = new();
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json, // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
// falling back to the retail-faithful defaults if the file is missing // falling back to the retail-faithful defaults if the file is missing
// or corrupt. This is THE single source of truth for the keymap at // or corrupt. This is THE single source of truth for the keymap at
@ -693,10 +696,6 @@ public sealed class GameWindow : IDisposable
_inputDispatcher.Attach(); _inputDispatcher.Attach();
_movementInput.Bind(_inputDispatcher); _movementInput.Bind(_inputDispatcher);
_cameraInput.Bind(_inputDispatcher); _cameraInput.Bind(_inputDispatcher);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
} }
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
@ -960,9 +959,12 @@ public sealed class GameWindow : IDisposable
getFrameMs: () => getFrameMs: () =>
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7), (float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
combat: Combat); combat: Combat);
_debugVm.CycleTimeOfDay = CycleTimeOfDay; _debugVm.CycleTimeOfDay =
_debugVm.CycleWeather = CycleWeather; _runtimeDiagnosticCommands.CycleTimeOfDay;
_debugVm.ToggleCollisionWires = ToggleCollisionWires; _debugVm.CycleWeather =
_runtimeDiagnosticCommands.CycleWeather;
_debugVm.ToggleCollisionWires =
_runtimeDiagnosticCommands.ToggleCollisionWireframes;
// Phase K.2: free-fly toggle button — same routine the // Phase K.2: free-fly toggle button — same routine the
// legacy F-key alias hits. Cancels the one-shot // legacy F-key alias hits. Cancels the one-shot
// auto-entry if the user opts out of player mode before // auto-entry if the user opts out of player mode before
@ -1406,8 +1408,6 @@ public sealed class GameWindow : IDisposable
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au), displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(), incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionHost?.IsInWorld == true); canSend: () => _liveSessionHost?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select. // Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the // UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the // same devices) is gated off via WantCaptureMouse/Keyboard above when the
@ -1577,7 +1577,7 @@ public sealed class GameWindow : IDisposable
UseItemByGuid, UseItemByGuid,
Combat, Combat,
ItemMana, ItemMana,
ToggleLiveCombatMode, _liveCombatModeCommands.Toggle,
_itemInteractionController, _itemInteractionController,
entry => LiveSession?.SendAddShortcut(entry), entry => LiveSession?.SendAddShortcut(entry),
index => LiveSession?.SendRemoveShortcut(index), index => LiveSession?.SendRemoveShortcut(index),
@ -2007,6 +2007,17 @@ public sealed class GameWindow : IDisposable
text => _debugVm?.AddToast(text), text => _debugVm?.AddToast(text),
_playerApproachCompletions); _playerApproachCompletions);
} }
if (_uiHost is { } retainedHost
&& _selectionInteractions is { } selectionInteractions)
{
_retainedUiGameplayBinding =
AcDream.App.Input.RetainedUiGameplayBinding.Create(
retainedHost.Root,
(item, x, y) =>
selectionInteractions.PlaceDraggedItem(item, x, y),
_hostQuiescence);
_retainedUiGameplayBinding.Attach();
}
// A.5 T22.5: apply A2C gate from quality preset. // A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -2813,6 +2824,81 @@ public sealed class GameWindow : IDisposable
_playerModeAutoEntry), _playerModeAutoEntry),
cameraFrame); cameraFrame);
_liveSessionHost = CreateLiveSessionHost(); _liveSessionHost = CreateLiveSessionHost();
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
Action<string>? debugToast = debugVm is null
? null
: message => debugVm.AddToast(message);
AcDream.Core.Chat.ChatLog chat = Chat;
var combatCommand =
new AcDream.App.Combat.LiveCombatModeCommandController(
new AcDream.App.Combat.LiveSessionCombatModeAuthority(
_liveSessionHost),
new AcDream.App.Combat.LocalPlayerCombatEquipmentSource(
Objects,
_localPlayerIdentity),
Combat,
new AcDream.App.Combat.ItemInteractionCombatModeIntentSink(
_itemInteractionController!),
Console.WriteLine,
debugToast,
text => chat.OnSystemMessage(text, 0x1Au));
_liveCombatModeCommands.Bind(combatCommand);
var nearbyDiagnostics =
new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper(
new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource(
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_liveWorldOrigin,
_worldState,
_physicsEngine),
Console.WriteLine);
var runtimeDiagnostics =
new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(
_worldEnvironment,
_worldSceneDebugState,
_cameraPointerInput,
nearbyDiagnostics,
debugToast);
_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);
if (_inputDispatcher is { } dispatcher)
{
var pointer = _cameraPointerInput
?? throw new InvalidOperationException(
"Gameplay action routing requires the composed pointer owner.");
var commands = new AcDream.App.Input.GameplayInputCommandController(
new AcDream.App.Input.RetainedGameplayWindowCommands(
_retailUiRuntime),
new AcDream.App.Input.DevToolsGameplayCommands(
_devToolsFramePresenter),
runtimeDiagnostics,
new AcDream.App.Input.PlayerModeGameplayCommands(
_localPlayerMode,
_playerModeController),
new AcDream.App.Input.ItemTargetModeCommands(
_itemInteractionController!),
new AcDream.App.Input.GameplayCameraModeCommands(
_cameraController!),
combatCommand,
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
_gameplayInputFrame!,
pointer,
_retailUiRuntime,
_selectionInteractions,
commands);
_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(
dispatcher,
Combat,
targets,
_hostQuiescence,
Console.WriteLine);
_gameplayInputActions.Attach();
}
AcDream.App.Net.LiveSessionStartResult liveStart = AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionHost.Start(_options); _liveSessionHost.Start(_options);
switch (liveStart.Status) switch (liveStart.Status)
@ -3241,36 +3327,6 @@ public sealed class GameWindow : IDisposable
private bool GetDebugPlayerOnGround() => private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne; _playerMode && _playerController is not null && !_playerController.IsAirborne;
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
string message = _worldEnvironment.CycleTimeOfDay();
_debugVm?.AddToast(message);
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
string message = _worldEnvironment.CycleWeather();
_debugVm?.AddToast(message);
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
_debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
// Phase K.3 settings state remains runtime-owned; the presenter owns the // Phase K.3 settings state remains runtime-owned; the presenter owns the
// optional SettingsPanel and its visibility/input operations. // optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm; private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
@ -3282,11 +3338,6 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default"; private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
return _retailUiRuntime?.ToggleWindow(name) ?? false;
}
private void SyncToolbarWindowButtons() private void SyncToolbarWindowButtons()
{ {
_retailUiRuntime?.SyncToolbarWindowButtons(); _retailUiRuntime?.SyncToolbarWindowButtons();
@ -3369,12 +3420,6 @@ public sealed class GameWindow : IDisposable
} }
} }
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
}
// L.0 follow-up: persisted-settings cache populated by // L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad, // LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction // not gated on DevToolsEnabled). The Settings PANEL construction
@ -3563,202 +3608,6 @@ public sealed class GameWindow : IDisposable
&& width > 0 && width > 0
&& height > 0; && height > 0;
} }
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
// handlers are gone; everything now flows through InputDispatcher.Fired
// → here. New behaviors register a new InputAction in the enum + a
// case in this switch + a binding in KeyBindings.
/// <summary>
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
/// Handles every game-side reaction to a keyboard/mouse-button chord.
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
{
// Diagnostic — kept from K.1a; helpful for K.1c verification.
Console.WriteLine($"[input] {action} {activation}");
// 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;
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
// cursor + activates yaw drive; release restores. WantCapture
// edge handling lives in OnUpdate; only Press needs to read it
// for the initial gate (defense in depth — the dispatcher
// already filters on WantCaptureMouse in OnMouseDown).
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
// wheel tick. Press is the only activation type for wheel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
|| action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown)
{
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
HandleScrollAction(action);
return;
}
// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic
// combat before the movement command enters CommandInterpreter. This
// notification does not consume the input; the movement owner below
// still receives it normally.
// Retail attack actions consume their full transition stream: key-down
// starts the power build and key-up commits it. Handle them before the
// generic Press-only gate below.
if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
return;
// Every other action fires on Press only (no Release / Hold side-
// effects in the K.1b set). Filter out non-Press activations early
// so subscribers that have Release-mode bindings don't accidentally
// re-fire. B.4b exception: DoubleClick must pass through so
// SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch.
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
// Wave 4.1: one retained-UI delegation seam. The runtime routes only
// toolbar-owned semantic actions; every other action continues through
// the game switch below. Keyboard capture is already enforced at the
// InputDispatcher source, matching retail's stack-entry focus gate.
if (_retailUiRuntime?.HandleInputAction(action) == true)
return;
if (_selectionInteractions?.HandleInputAction(action) == true)
return;
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
// Q to start, press Q again to stop. Pressing Backup / Stop /
// StrafeLeft / StrafeRight while autorun is active also cancels it
// — retail-faithful: any deliberate movement input wins. (Pressing
// Forward AGAIN does NOT cancel — retail's autorun stays active
// even when you press W; the two stack.)
if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
return;
switch (action)
{
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
_devToolsFramePresenter?.ToggleDebugPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWires();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby:
DumpPlayerAndNearbyEntities();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown:
AdjustActiveSensitivity(1f / 1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp:
AdjustActiveSensitivity(1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather:
CycleWeather();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
// K-fix3 (2026-04-26): proper round-trip when player has
// an active chase camera. ToggleFly() only swaps
// Fly↔Orbit, so a user who flew out of player mode used
// to land in Holtburg-orbit on toggle-back. With a chase
// camera available, prefer Fly→Chase / Chase→Fly so the
// user round-trips back to the same player view.
_playerModeController?.ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
_playerModeController?.Toggle();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
// sets a one-shot flag that emits SetKeyboardFocusHere on
// the next render. No-op when the devtools presenter is absent
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
_devToolsFramePresenter?.FocusChatInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
// K.3: F11 toggles the Settings panel. Null-safe vs.
// devtools-off / panel-not-registered — the [input] log
// line above still records the press regardless.
_devToolsFramePresenter?.ToggleSettingsPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_itemInteractionController?.IsAnyTargetModeActive == true)
_itemInteractionController.CancelTargetMode();
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
_playerModeController?.Exit();
else
_window!.Close();
break;
}
}
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
Objects.GetEquippedBy(_playerServerGuid);
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
.GetDefaultCombatMode(orderedEquipment);
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
_debugVm?.AddToast(text);
}
/// <summary> /// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the /// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs /// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
@ -3843,87 +3692,6 @@ public sealed class GameWindow : IDisposable
return unhydratable; return unhydratable;
} }
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
/// given factor and clamps to [0.005, 3.0].
/// </summary>
private void AdjustActiveSensitivity(float factor)
{
if (_cameraPointerInput is not { } pointer)
return;
string message = pointer.AdjustSensitivity(factor);
_debugVm?.AddToast(message);
}
/// <summary>
/// K.1b: F3 dump handler extracted into a method. Same body as the
/// previous in-line F3 branch — prints the player's position +
/// nearby visible entities + nearby shadow physics objects.
/// </summary>
private void DumpPlayerAndNearbyEntities()
{
System.Numerics.Vector3 pos;
if (_playerMode && _playerController is not null)
pos = _playerController.Position;
else
{
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
Console.WriteLine(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
$" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" +
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
foreach (var e in _worldState.Entities)
{
float dx = e.Position.X - pos.X;
float dy = e.Position.Y - pos.Y;
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
}
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
{
float d = (e.Position - pos).Length();
Console.WriteLine(
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
}
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = o.Position.X - pos.X;
float dy = o.Position.Y - pos.Y;
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d < 15f) sorted.Add((o, d));
}
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
foreach (var (o, d) in sorted.Take(12))
{
Console.WriteLine(
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
}
}
/// <summary>
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
/// camera distance is current — chase camera distance in player mode,
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
/// is fixed-step (the previous proportional scroll.Y was lost when we
/// moved scroll into the dispatcher, but the discrete step matches
/// retail wheel feel).
/// </summary>
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
=> _cameraPointerInput?.HandleScroll(action);
private void OnClosing() private void OnClosing()
{ {
_hostQuiescence.StopAccepting(); _hostQuiescence.StopAccepting();
@ -3956,6 +3724,10 @@ public sealed class GameWindow : IDisposable
// are already inert. // are already inert.
new ResourceShutdownStage("input callback deactivation", new ResourceShutdownStage("input callback deactivation",
[ [
new("combat command slot", _liveCombatModeCommands.Deactivate),
new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate),
new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()),
new("gameplay actions", () => _gameplayInputActions?.Deactivate()),
new("camera pointer", () => _cameraPointerInput?.Deactivate()), new("camera pointer", () => _cameraPointerInput?.Deactivate()),
new("dispatcher", () => _inputDispatcher?.Deactivate()), new("dispatcher", () => _inputDispatcher?.Deactivate()),
new("mouse source", () => _mouseSource?.Deactivate()), new("mouse source", () => _mouseSource?.Deactivate()),
@ -3991,6 +3763,28 @@ public sealed class GameWindow : IDisposable
]), ]),
new ResourceShutdownStage("input callback detach", new ResourceShutdownStage("input callback detach",
[ [
new("retained gameplay", () =>
{
AcDream.App.Input.RetainedUiGameplayBinding? binding =
_retainedUiGameplayBinding;
if (binding is null) return;
binding.Dispose();
if (!binding.IsDisposalComplete)
throw new InvalidOperationException(
"Retained gameplay callback removal remains pending.");
_retainedUiGameplayBinding = null;
}),
new("gameplay actions", () =>
{
AcDream.App.Input.GameplayInputActionRouter? actions =
_gameplayInputActions;
if (actions is null) return;
actions.Dispose();
if (!actions.IsDisposalComplete)
throw new InvalidOperationException(
"Gameplay action callback removal remains pending.");
_gameplayInputActions = null;
}),
new("retained UI input", () => _uiHost?.DeactivateInput()), new("retained UI input", () => _uiHost?.DeactivateInput()),
new("developer tools input", () => _devToolsInputContext?.Dispose()), new("developer tools input", () => _devToolsInputContext?.Dispose()),
new("camera pointer", () => new("camera pointer", () =>
@ -4003,14 +3797,11 @@ public sealed class GameWindow : IDisposable
throw new InvalidOperationException( throw new InvalidOperationException(
"Camera pointer callback removal remains pending."); "Camera pointer callback removal remains pending.");
}), }),
new("combat input subscription", () =>
Combat.CombatModeChanged -= SetInputCombatScope),
new("dispatcher", () => new("dispatcher", () =>
{ {
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher = AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
_inputDispatcher; _inputDispatcher;
if (dispatcher is null) return; if (dispatcher is null) return;
dispatcher.Fired -= OnInputAction;
_movementInput.Unbind(dispatcher); _movementInput.Unbind(dispatcher);
_cameraInput.Unbind(dispatcher); _cameraInput.Unbind(dispatcher);
dispatcher.Dispose(); dispatcher.Dispose();

View file

@ -36,6 +36,14 @@ public enum CombatAttackAction
High, High,
} }
/// <summary>
/// Retail's default-mode lookup returns the selected mode and, on the one
/// rejected held-item branch, the object used to format its user notice.
/// </summary>
public readonly record struct DefaultCombatModeDecision(
CombatMode Mode,
ClientObject? IncompatibleHeldItem);
/// <summary> /// <summary>
/// Retail input-facing combat decisions. The heavyweight parts of the combat /// Retail input-facing combat decisions. The heavyweight parts of the combat
/// system remain server authoritative; this helper only maps UI intent to the /// system remain server authoritative; this helper only maps UI intent to the
@ -92,6 +100,14 @@ public static class CombatInputPlanner
/// object intersecting the requested location mask. /// object intersecting the requested location mask.
/// </summary> /// </summary>
public static CombatMode GetDefaultCombatMode( public static CombatMode GetDefaultCombatMode(
IReadOnlyList<ClientObject> orderedPlayerContents) =>
GetDefaultCombatModeDecision(orderedPlayerContents).Mode;
/// <summary>
/// Complete result of retail <c>GetDefaultCombatMode</c>, including the
/// incompatible Held object used by its exact failure notice.
/// </summary>
public static DefaultCombatModeDecision GetDefaultCombatModeDecision(
IReadOnlyList<ClientObject> orderedPlayerContents) IReadOnlyList<ClientObject> orderedPlayerContents)
{ {
ArgumentNullException.ThrowIfNull(orderedPlayerContents); ArgumentNullException.ThrowIfNull(orderedPlayerContents);
@ -102,20 +118,22 @@ public static class CombatInputPlanner
{ {
// Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in // Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in
// this primary weapon slot selects melee. // this primary weapon slot selects melee.
return weapon.CombatUse == 2 return new(
? CombatMode.Missile weapon.CombatUse == 2
: CombatMode.Melee; ? CombatMode.Missile
: CombatMode.Melee,
IncompatibleHeldItem: null);
} }
ClientObject? held = GetObjectAtLocation( ClientObject? held = GetObjectAtLocation(
orderedPlayerContents, EquipMask.Held); orderedPlayerContents, EquipMask.Held);
if (held is null) if (held is null)
return CombatMode.Melee; return new(CombatMode.Melee, IncompatibleHeldItem: null);
// The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster). // The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster).
return (held.Type & ItemType.Caster) != 0 return (held.Type & ItemType.Caster) != 0
? CombatMode.Magic ? new(CombatMode.Magic, IncompatibleHeldItem: null)
: CombatMode.NonCombat; : new(CombatMode.NonCombat, held);
} }
private static ClientObject? GetObjectAtLocation( private static ClientObject? GetObjectAtLocation(
@ -136,12 +154,13 @@ public static class CombatInputPlanner
CombatMode currentMode, CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee) CombatMode defaultCombatMode = CombatMode.Melee)
{ {
if ((currentMode & CombatMode.CombatCombat) != 0) // ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0 compares the
// current value directly with NonCombat. It does not coerce a
// rejected/default NonCombat result to unarmed Melee.
if (currentMode != CombatMode.NonCombat)
return CombatMode.NonCombat; return CombatMode.NonCombat;
return (defaultCombatMode & CombatMode.CombatCombat) != 0 return defaultCombatMode;
? defaultCombatMode
: CombatMode.Melee;
} }
public static bool SupportsTargetedAttack(CombatMode mode) => public static bool SupportsTargetedAttack(CombatMode mode) =>

View file

@ -0,0 +1,190 @@
using AcDream.App.Combat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Tests.Combat;
public sealed class LiveCombatModeCommandControllerTests
{
[Fact]
public void OutsideWorld_IsCompleteNoOpBeforeEquipmentQuery()
{
var harness = new Harness { Authority = { IsInWorldValue = false } };
harness.Controller.Toggle();
Assert.Equal(0, harness.Equipment.QueryCount);
Assert.Empty(harness.Calls);
Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode);
}
[Fact]
public void PeaceWithBow_PreservesIntentSendLocalStateFeedbackOrder()
{
var harness = new Harness();
harness.Equipment.Items.Add(new ClientObject
{
ObjectId = 1,
CurrentlyEquippedLocation = EquipMask.MissileWeapon,
Type = ItemType.MissileWeapon,
CombatUse = 2,
});
harness.Combat.CombatModeChanged += mode =>
harness.Calls.Add($"state:{mode}");
harness.Controller.Toggle();
Assert.Equal(1, harness.Equipment.QueryCount);
Assert.Equal(CombatMode.Missile, harness.Combat.CurrentMode);
Assert.Equal(
[
"intent",
"send:Missile",
"state:Missile",
"log:combat: Combat mode Missile",
"toast:Combat mode Missile",
],
harness.Calls);
}
[Fact]
public void ActiveCombat_TogglesToPeaceRegardlessOfEquipmentDefault()
{
var harness = new Harness();
harness.Combat.SetCombatMode(CombatMode.Magic);
harness.Controller.Toggle();
Assert.Equal(0, harness.Equipment.QueryCount);
Assert.Equal([CombatMode.NonCombat], harness.Authority.Requests);
Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode);
}
[Fact]
public void PeaceWithIncompatibleHeldItem_PrintsRetailNoticeWithoutSending()
{
var harness = new Harness();
harness.Equipment.Items.Add(new ClientObject
{
ObjectId = 1,
Name = "Torch",
CurrentlyEquippedLocation = EquipMask.Held,
Type = ItemType.Misc,
});
harness.Controller.Toggle();
Assert.Empty(harness.Authority.Requests);
Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode);
Assert.Equal(
[
"intent",
"log:combat: You can't enter combat mode while wielding the Torch",
"system:You can't enter combat mode while wielding the Torch",
],
harness.Calls);
}
[Fact]
public void TransportFailure_LeavesLocalModeAndFeedbackUnpublished()
{
var harness = new Harness { Authority = { ThrowOnSend = true } };
Assert.Throws<InvalidOperationException>(harness.Controller.Toggle);
Assert.Equal(["intent", "send:Melee"], harness.Calls);
Assert.Equal(CombatMode.NonCombat, harness.Combat.CurrentMode);
}
[Fact]
public void DeferredSlot_IsNoOpBeforeBindAndForwardsOneOwnerAfterBind()
{
var slot = new LiveCombatModeCommandSlot();
var target = new FakeCommand();
slot.Toggle();
slot.Bind(target);
slot.Toggle();
slot.Bind(target);
Assert.Equal(1, target.ToggleCount);
Assert.Throws<InvalidOperationException>(
() => slot.Bind(new FakeCommand()));
slot.Unbind(target);
slot.Toggle();
Assert.Equal(1, target.ToggleCount);
slot.Bind(target);
Action copied = slot.Toggle;
slot.Deactivate();
copied();
Assert.Equal(1, target.ToggleCount);
Assert.Throws<ObjectDisposedException>(() => slot.Bind(target));
}
private sealed class Harness
{
public Harness()
{
Authority = new FakeAuthority(Calls);
Equipment = new FakeEquipment();
Intent = new FakeIntent(Calls);
Controller = new LiveCombatModeCommandController(
Authority,
Equipment,
Combat,
Intent,
line => Calls.Add($"log:{line}"),
line => Calls.Add($"toast:{line}"),
line => Calls.Add($"system:{line}"));
}
public List<string> Calls { get; } = [];
public FakeAuthority Authority { get; }
public FakeEquipment Equipment { get; }
public CombatState Combat { get; } = new();
public FakeIntent Intent { get; }
public LiveCombatModeCommandController Controller { get; }
}
private sealed class FakeAuthority(List<string> calls)
: ILiveCombatModeAuthority
{
public bool IsInWorldValue { get; set; } = true;
public bool IsInWorld => IsInWorldValue;
public bool ThrowOnSend { get; set; }
public List<CombatMode> Requests { get; } = [];
public void SendChangeCombatMode(CombatMode mode)
{
calls.Add($"send:{mode}");
if (ThrowOnSend)
throw new InvalidOperationException("send");
Requests.Add(mode);
}
}
private sealed class FakeEquipment : ICombatEquipmentSource
{
public List<ClientObject> Items { get; } = [];
public int QueryCount { get; private set; }
public IReadOnlyList<ClientObject> GetOrderedEquipment()
{
QueryCount++;
return Items;
}
}
private sealed class FakeIntent(List<string> calls)
: IExplicitCombatModeIntentSink
{
public void NotifyExplicitCombatModeRequest() => calls.Add("intent");
}
private sealed class FakeCommand : ILiveCombatModeCommand
{
public int ToggleCount { get; private set; }
public void Toggle() => ToggleCount++;
}
}

View file

@ -0,0 +1,185 @@
using System.Numerics;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Diagnostics;
[Collection(AcDream.App.Tests.World.WorldEnvironmentControllerCollection.Name)]
public sealed class RuntimeDiagnosticCommandControllerTests
{
[Fact]
public void Handle_RoutesCanonicalEnvironmentSceneAndDumpOwners()
{
var source = new FakeNearbySource();
var logs = new List<string>();
var toasts = new List<string>();
var scene = new WorldSceneDebugState();
var controller = new RuntimeDiagnosticCommandController(
new WorldEnvironmentController(),
scene,
pointer: null,
new NearbyWorldDiagnosticDumper(source, logs.Add),
toasts.Add);
Assert.True(controller.Handle(InputAction.AcdreamToggleCollisionWires));
Assert.True(scene.CollisionWireframesVisible);
Assert.True(controller.Handle(InputAction.AcdreamCycleTimeOfDay));
Assert.True(controller.Handle(InputAction.AcdreamCycleWeather));
Assert.True(controller.Handle(InputAction.AcdreamDumpNearby));
Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown));
Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp));
Assert.False(controller.Handle(InputAction.MovementForward));
Assert.Contains("Collision wireframes ON", toasts);
Assert.Contains("Time override = 0.00", toasts);
Assert.Contains("Weather = Overcast", toasts);
Assert.Equal(3, toasts.Count);
Assert.Contains(logs, line => line.StartsWith(
"=== F3 DEBUG DUMP ===", StringComparison.Ordinal));
}
[Fact]
public void NearbyDump_PreservesLandblockCoordinatesRangeAndDistanceOrder()
{
var source = new FakeNearbySource
{
ObserverPositionValue = new Vector3(200f, -1f, 3f),
LiveCenterXValue = 10,
LiveCenterYValue = 20,
TotalShadowObjectsValue = 4,
};
source.Entities.Add(Entity(2, new Vector3(203f, -1f, 3f)));
source.Entities.Add(Entity(1, new Vector3(201f, -1f, 3f)));
source.Entities.Add(Entity(3, new Vector3(216f, -1f, 3f)));
source.Shadows.Add(new ShadowEntry(
EntityId: 5,
GfxObjId: 6,
Position: new Vector3(202f, -1f, 3f),
Rotation: Quaternion.Identity,
Radius: 0.5f,
CollisionType: ShadowCollisionType.Sphere));
var lines = new List<string>();
new NearbyWorldDiagnosticDumper(source, lines.Add).Dump();
Assert.Contains("landblock=0x0B13FFFF local=(8.00,191.00)", lines[0]);
Assert.Contains("total shadow objects: 4", lines[0]);
Assert.Equal(" VISIBLE entities within 15m: 2", lines[1]);
Assert.Contains("id=0x00000001", lines[2]);
Assert.Contains("id=0x00000002", lines[3]);
Assert.Equal(" SHADOW objects within 15m: 1", lines[4]);
Assert.Contains("id=0x00000005", lines[5]);
}
[Fact]
public void DeferredSlot_ClaimsKnownActionsBeforeBindAndForwardsAfterBind()
{
var slot = new RuntimeDiagnosticCommandSlot();
var target = new FakeCommands();
Assert.True(slot.Handle(InputAction.AcdreamDumpNearby));
Assert.False(slot.Handle(InputAction.MovementForward));
slot.CycleTimeOfDay();
slot.Bind(target);
slot.Bind(target);
Assert.True(slot.Handle(InputAction.AcdreamDumpNearby));
slot.CycleTimeOfDay();
slot.CycleWeather();
slot.ToggleCollisionWireframes();
Assert.Equal(4, target.CallCount);
Assert.Throws<InvalidOperationException>(
() => slot.Bind(new FakeCommands()));
slot.Unbind(target);
slot.CycleWeather();
Assert.Equal(4, target.CallCount);
slot.Bind(target);
Action copied = slot.CycleWeather;
slot.Deactivate();
copied();
Assert.Equal(4, target.CallCount);
Assert.Throws<ObjectDisposedException>(() => slot.Bind(target));
}
[Fact]
public void NullToast_DoesNotSuppressDiagnosticStateChanges()
{
var environment = new WorldEnvironmentController();
var scene = new WorldSceneDebugState();
var sensitivity = new FakeSensitivity();
var controller = new RuntimeDiagnosticCommandController(
environment,
scene,
sensitivity,
new NearbyWorldDiagnosticDumper(new FakeNearbySource()),
toast: null);
controller.CycleTimeOfDay();
controller.CycleWeather();
controller.ToggleCollisionWireframes();
Assert.True(controller.Handle(InputAction.AcdreamSensitivityDown));
Assert.True(controller.Handle(InputAction.AcdreamSensitivityUp));
Assert.Equal(0f, environment.DayFraction);
Assert.Equal(WeatherKind.Overcast, environment.Weather.Kind);
Assert.True(scene.CollisionWireframesVisible);
Assert.Equal(2, sensitivity.AdjustCount);
}
private static WorldEntity Entity(uint id, Vector3 position) => new()
{
Id = id,
SourceGfxObjOrSetupId = id + 100,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = [],
};
private sealed class FakeNearbySource : INearbyWorldDiagnosticSource
{
public Vector3 ObserverPositionValue { get; set; }
public int LiveCenterXValue { get; set; }
public int LiveCenterYValue { get; set; }
public int TotalShadowObjectsValue { get; set; }
public List<WorldEntity> Entities { get; } = [];
public List<ShadowEntry> Shadows { get; } = [];
public Vector3 ObserverPosition => ObserverPositionValue;
public int LiveCenterX => LiveCenterXValue;
public int LiveCenterY => LiveCenterYValue;
public int TotalShadowObjects => TotalShadowObjectsValue;
public IReadOnlyList<WorldEntity> WorldEntities => Entities;
public IEnumerable<ShadowEntry> ShadowEntries => Shadows;
}
private sealed class FakeCommands : IRuntimeDiagnosticCommands
{
public int CallCount { get; private set; }
public bool Handle(InputAction action)
{
CallCount++;
return true;
}
public void CycleTimeOfDay() => CallCount++;
public void CycleWeather() => CallCount++;
public void ToggleCollisionWireframes() => CallCount++;
}
private sealed class FakeSensitivity : IPointerSensitivityCommands
{
public int AdjustCount { get; private set; }
public string AdjustSensitivity(float factor)
{
AdjustCount++;
return $"sensitivity {factor}";
}
}
}

View file

@ -0,0 +1,328 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class GameplayInputActionRouterTests
{
[Theory]
[InlineData("pointer", "pointer")]
[InlineData("combat", "pointer,combat")]
[InlineData("retained", "pointer,combat,retained")]
[InlineData("selection", "pointer,combat,retained,selection")]
[InlineData("movement", "pointer,combat,retained,selection,movement")]
[InlineData("command", "pointer,combat,retained,selection,movement,command")]
public void Press_PreservesFrozenPriorityAndStopsAtConsumer(
string consumeAt,
string expectedCsv)
{
var harness = new Harness(consumeAt);
harness.Router.Attach();
harness.Actions.Raise(
InputAction.AcdreamToggleDebugPanel,
ActivationType.Press);
Assert.Equal(expectedCsv.Split(','), harness.Targets.Calls);
}
[Fact]
public void Scroll_IsBetweenPointerAndCombat_AndOnlyPressMovesIt()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(InputAction.ScrollUp, ActivationType.Release);
Assert.Equal(["pointer"], harness.Targets.Calls);
harness.Targets.Calls.Clear();
harness.Actions.Raise(InputAction.ScrollDown, ActivationType.Press);
Assert.Equal(["pointer", "scroll:ScrollDown"], harness.Targets.Calls);
}
[Fact]
public void Release_ReachesCombatThenStopsAtPressGate()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(
InputAction.CombatHighAttack,
ActivationType.Release);
Assert.Equal(["pointer", "combat"], harness.Targets.Calls);
}
[Fact]
public void DoubleClick_PassesGateToRetainedSelectionMovementAndCommands()
{
var harness = new Harness();
harness.Router.Attach();
harness.Actions.Raise(
InputAction.SelectDblLeft,
ActivationType.DoubleClick);
Assert.Equal(
["pointer", "combat", "retained", "selection", "movement", "command"],
harness.Targets.Calls);
}
[Fact]
public void Attach_SeedsAndTracksCombatScopes()
{
var harness = new Harness();
harness.Combat.CurrentModeValue = CombatMode.Missile;
harness.Router.Attach();
harness.Combat.Raise(CombatMode.Magic);
harness.Combat.Raise(CombatMode.NonCombat);
Assert.Equal(
[InputScope.MissileCombat, InputScope.MagicCombat, null],
harness.Actions.Scopes);
}
[Theory]
[InlineData(0, "remove-actions")]
[InlineData(1, "remove-combat,remove-actions")]
public void AttachFailureAfterSideEffect_RollsBackExactPrefixInReverse(
int failingEdge,
string expectedRemovalCsv)
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
ThrowAfterAdd = failingEdge == 0,
};
var combat = new FakeCombatSurface(calls)
{
ThrowAfterAdd = failingEdge == 1,
};
var router = Create(actions, combat, new FakeTargets());
Assert.Throws<InvalidOperationException>(router.Attach);
Assert.Equal(expectedRemovalCsv.Split(','), calls.Where(
call => call.StartsWith("remove", StringComparison.Ordinal)));
Assert.True(router.IsDisposalComplete);
}
[Fact]
public void ReentrantDeliveryDuringAdd_CannotEnterPartialRouter()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
RaiseDuringAdd = true,
};
var targets = new FakeTargets();
var router = Create(actions, new FakeCombatSurface(calls), targets);
router.Attach();
Assert.Empty(targets.Calls);
actions.Raise(InputAction.AcdreamToggleDebugPanel, ActivationType.Press);
Assert.NotEmpty(targets.Calls);
}
[Fact]
public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls)
{
ThrowAfterAdd = true,
RemoveFailures = int.MaxValue,
};
var router = Create(
actions,
new FakeCombatSurface(calls),
new FakeTargets());
Assert.Throws<AggregateException>(router.Attach);
Assert.False(router.IsDisposalComplete);
actions.RemoveFailures = 0;
router.Dispose();
Assert.True(router.IsDisposalComplete);
Assert.Null(actions.Callback);
}
[Fact]
public void Dispose_RetriesOnlyFailedRemovalAndNeverReplaysSuccess()
{
var calls = new List<string>();
var actions = new FakeActionSurface(calls);
var combat = new FakeCombatSurface(calls)
{
RemoveFailures = int.MaxValue,
};
var router = Create(actions, combat, new FakeTargets());
router.Attach();
Assert.Throws<AggregateException>(router.Dispose);
Assert.False(router.IsDisposalComplete);
combat.RemoveFailures = 0;
router.Dispose();
Assert.True(router.IsDisposalComplete);
Assert.Equal(4, calls.Count(call => call == "remove-combat"));
Assert.Equal(1, calls.Count(call => call == "remove-actions"));
}
[Fact]
public void Deactivate_MakesCopiedCallbacksSilentBeforePhysicalDetach()
{
var harness = new Harness();
harness.Router.Attach();
Action<InputAction, ActivationType> copiedAction =
harness.Actions.Callback!;
Action<CombatMode> copiedCombat = harness.Combat.Callback!;
harness.Router.Deactivate();
copiedAction(InputAction.AcdreamToggleDebugPanel, ActivationType.Press);
copiedCombat(CombatMode.Melee);
Assert.Empty(harness.Targets.Calls);
Assert.Equal([null], harness.Actions.Scopes);
}
[Fact]
public void DisposeBeforeAttach_IsTerminalAndCannotResurrectCallbacks()
{
var harness = new Harness();
harness.Router.Dispose();
Assert.True(harness.Router.IsDisposalComplete);
Assert.Throws<ObjectDisposedException>(harness.Router.Attach);
Assert.Null(harness.Actions.Callback);
Assert.Null(harness.Combat.Callback);
}
private static GameplayInputActionRouter Create(
FakeActionSurface actions,
FakeCombatSurface combat,
FakeTargets targets) =>
new(actions, combat, targets, new HostQuiescenceGate());
private sealed class Harness
{
public Harness(string? consumeAt = null)
{
var calls = new List<string>();
Actions = new FakeActionSurface(calls);
Combat = new FakeCombatSurface(calls);
Targets = new FakeTargets(consumeAt);
Router = Create(Actions, Combat, Targets);
}
public FakeActionSurface Actions { get; }
public FakeCombatSurface Combat { get; }
public FakeTargets Targets { get; }
public GameplayInputActionRouter Router { get; }
}
private sealed class FakeActionSurface(List<string> calls)
: IGameplayInputActionSurface
{
public Action<InputAction, ActivationType>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public bool RaiseDuringAdd { get; set; }
public int RemoveFailures { get; set; }
public List<InputScope?> Scopes { get; } = [];
public void AddFired(Action<InputAction, ActivationType> callback)
{
calls.Add("add-actions");
Callback = callback;
if (RaiseDuringAdd)
{
callback(
InputAction.AcdreamToggleDebugPanel,
ActivationType.Press);
}
if (ThrowAfterAdd)
throw new InvalidOperationException("actions add");
}
public void RemoveFired(Action<InputAction, ActivationType> callback)
{
calls.Add("remove-actions");
if (RemoveFailures-- > 0)
throw new InvalidOperationException("actions remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void SetCombatScope(InputScope? scope) => Scopes.Add(scope);
public void Raise(InputAction action, ActivationType activation) =>
Callback?.Invoke(action, activation);
}
private sealed class FakeCombatSurface(List<string> calls)
: ICombatModeEventSurface
{
public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat;
public CombatMode CurrentMode => CurrentModeValue;
public Action<CombatMode>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public int RemoveFailures { get; set; }
public void AddChanged(Action<CombatMode> callback)
{
calls.Add("add-combat");
Callback = callback;
if (ThrowAfterAdd)
throw new InvalidOperationException("combat add");
}
public void RemoveChanged(Action<CombatMode> callback)
{
calls.Add("remove-combat");
if (RemoveFailures-- > 0)
throw new InvalidOperationException("combat remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void Raise(CombatMode mode) => Callback?.Invoke(mode);
}
private sealed class FakeTargets(string? consumeAt = null)
: IGameplayInputPriorityTargets
{
public List<string> Calls { get; } = [];
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
Record("pointer");
public void HandleScroll(InputAction action) => Calls.Add($"scroll:{action}");
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
Record("combat");
public bool HandleRetainedUiAction(InputAction action) =>
Record("retained");
public bool HandleSelectionAction(InputAction action) =>
Record("selection");
public bool HandlePressedMovementAction(InputAction action) =>
Record("movement");
public void HandleCommand(InputAction action) => Calls.Add("command");
private bool Record(string name)
{
Calls.Add(name);
return consumeAt == name;
}
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class GameplayInputCommandControllerTests
{
[Theory]
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
public void RecognizedCommand_RoutesToTypedOwner(
InputAction action,
string expected)
{
var harness = new Harness();
bool handled = harness.Controller.Handle(action);
Assert.True(handled);
Assert.Equal([expected], harness.Calls);
}
[Fact]
public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
{
var harness = new Harness();
harness.Diagnostics.HandledAction = InputAction.AcdreamToggleDebugPanel;
bool handled = harness.Controller.Handle(
InputAction.AcdreamToggleDebugPanel);
Assert.True(handled);
Assert.Equal(["diagnostic"], harness.Calls);
}
[Fact]
public void UnknownCommand_IsNotClaimed()
{
var harness = new Harness();
bool handled = harness.Controller.Handle(InputAction.MovementForward);
Assert.False(handled);
Assert.Empty(harness.Calls);
}
[Theory]
[InlineData(true, true, true, "cancel-target")]
[InlineData(false, true, true, "exit-fly")]
[InlineData(false, false, true, "exit-player")]
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetFlyPlayerWindowPriority(
bool targetMode,
bool flyMode,
bool playerMode,
string expected)
{
var harness = new Harness
{
TargetMode = { IsActive = targetMode },
Camera = { IsFly = flyMode },
Player = { IsPlayer = playerMode },
};
bool handled = harness.Controller.Handle(InputAction.EscapeKey);
Assert.True(handled);
Assert.Equal([expected], harness.Calls);
}
private sealed class Harness
{
public Harness()
{
Retained = new FakeRetained(Calls);
DevTools = new FakeDevTools(Calls);
Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls);
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
DevTools,
Diagnostics,
Player,
TargetMode,
Camera,
Combat,
Window);
}
public List<string> Calls { get; } = [];
public FakeRetained Retained { get; }
public FakeDevTools DevTools { get; }
public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; }
}
private sealed class FakeRetained(List<string> calls)
: IRetainedGameplayWindowCommands
{
public void ToggleInventory() => calls.Add("inventory");
}
private sealed class FakeDevTools(List<string> calls)
: IDevToolsGameplayCommands
{
public void ToggleDebugPanel() => calls.Add("debug");
public void FocusChatInput() => calls.Add("chat");
public void ToggleSettingsPanel() => calls.Add("settings");
}
private sealed class FakeDiagnostics(List<string> calls)
: IRuntimeDiagnosticCommands
{
public InputAction? HandledAction { get; set; }
public bool Handle(InputAction action)
{
if (action != HandledAction)
return false;
calls.Add("diagnostic");
return true;
}
public void CycleTimeOfDay() => calls.Add("time");
public void CycleWeather() => calls.Add("weather");
public void ToggleCollisionWireframes() => calls.Add("collision");
}
private sealed class FakePlayerMode(List<string> calls)
: IPlayerModeGameplayCommands
{
public bool IsPlayer { get; set; }
public bool IsPlayerMode => IsPlayer;
public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
public void TogglePlayerMode() => calls.Add("player-mode");
public void ExitPlayerMode() => calls.Add("exit-player");
}
private sealed class FakeTargetMode(List<string> calls)
: IItemTargetModeCommands
{
public bool IsActive { get; set; }
public bool IsAnyTargetModeActive => IsActive;
public void CancelTargetMode() => calls.Add("cancel-target");
}
private sealed class FakeCamera(List<string> calls)
: IGameplayCameraModeCommands
{
public bool IsFly { get; set; }
public bool IsFlyMode => IsFly;
public void ExitFlyMode() => calls.Add("exit-fly");
}
private sealed class FakeCombat(List<string> calls) : ILiveCombatModeCommand
{
public void Toggle() => calls.Add("combat");
}
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
{
public void Close() => calls.Add("close");
}
}

View file

@ -0,0 +1,158 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Tests.Input;
public sealed class RetainedUiGameplayBindingTests
{
[Fact]
public void AttachedBinding_RoutesOnlyItemPayloads()
{
var surface = new Surface();
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
surface.Raise("not an item", 1, 2);
surface.Raise(Item(42), 7, 9);
Assert.Equal([(42u, 7, 9)], calls);
}
[Fact]
public void AddAfterSideEffectFailure_RollsBackExactCallback()
{
var surface = new Surface { ThrowAfterAdd = true };
var binding = Create(surface, []);
Assert.Throws<InvalidOperationException>(binding.Attach);
Assert.True(binding.IsDisposalComplete);
Assert.Null(surface.Callback);
Assert.Equal(1, surface.RemoveCalls);
}
[Fact]
public void ReentrantDeliveryDuringAdd_CannotEnterPartialBinding()
{
var surface = new Surface { RaiseDuringAdd = true };
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
Assert.Empty(calls);
surface.Raise(Item(2), 3, 4);
Assert.Equal([(2u, 3, 4)], calls);
}
[Fact]
public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose()
{
var surface = new Surface
{
ThrowAfterAdd = true,
RemoveFailures = int.MaxValue,
};
var binding = Create(surface, []);
Assert.Throws<AggregateException>(binding.Attach);
Assert.False(binding.IsDisposalComplete);
surface.RemoveFailures = 0;
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Null(surface.Callback);
}
[Fact]
public void Deactivate_MakesCopiedCallbackSilentBeforePhysicalDetach()
{
var surface = new Surface();
var calls = new List<(uint Id, int X, int Y)>();
var binding = Create(surface, calls);
binding.Attach();
Action<object, int, int> copied = surface.Callback!;
binding.Deactivate();
copied(Item(8), 1, 1);
Assert.Empty(calls);
Assert.False(binding.IsDisposalComplete);
}
[Fact]
public void Dispose_RetriesFailedRemovalWithoutReattaching()
{
var surface = new Surface { RemoveFailures = int.MaxValue };
var binding = Create(surface, []);
binding.Attach();
Assert.Throws<AggregateException>(binding.Dispose);
Assert.False(binding.IsDisposalComplete);
surface.RemoveFailures = 0;
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Equal(1, surface.AddCalls);
Assert.Equal(3, surface.RemoveCalls);
}
[Fact]
public void DisposeBeforeAttach_IsTerminal()
{
var surface = new Surface();
var binding = Create(surface, []);
binding.Dispose();
Assert.True(binding.IsDisposalComplete);
Assert.Throws<ObjectDisposedException>(binding.Attach);
Assert.Null(surface.Callback);
}
private static RetainedUiGameplayBinding Create(
Surface surface,
List<(uint Id, int X, int Y)> calls) =>
new(
surface,
(item, x, y) => calls.Add((item.ObjId, x, y)),
new HostQuiescenceGate());
private static ItemDragPayload Item(uint id) =>
new(id, ItemDragSource.Inventory, 0, SourceCell: null!);
private sealed class Surface : IRetainedUiDragReleaseSurface
{
public Action<object, int, int>? Callback { get; private set; }
public bool ThrowAfterAdd { get; set; }
public bool RaiseDuringAdd { get; set; }
public int RemoveFailures { get; set; }
public int AddCalls { get; private set; }
public int RemoveCalls { get; private set; }
public void AddReleasedOutside(Action<object, int, int> callback)
{
AddCalls++;
Callback = callback;
if (RaiseDuringAdd)
callback(Item(1), 1, 1);
if (ThrowAfterAdd)
throw new InvalidOperationException("add");
}
public void RemoveReleasedOutside(Action<object, int, int> callback)
{
RemoveCalls++;
if (RemoveFailures-- > 0)
throw new InvalidOperationException("remove");
if (ReferenceEquals(Callback, callback))
Callback = null;
}
public void Raise(object payload, int x, int y) =>
Callback?.Invoke(payload, x, y);
}
}

View file

@ -62,7 +62,6 @@ public sealed class GameWindowSlice8BoundaryTests
"_inputDispatcher.Attach();", "_inputDispatcher.Attach();",
"_movementInput.Bind(_inputDispatcher);", "_movementInput.Bind(_inputDispatcher);",
"_cameraInput.Bind(_inputDispatcher);", "_cameraInput.Bind(_inputDispatcher);",
"_inputDispatcher.Fired += OnInputAction;",
"_cameraController = new CameraController(orbit, fly);", "_cameraController = new CameraController(orbit, fly);",
"_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(", "_cameraPointerInput = AcDream.App.Input.CameraPointerInputController.Create(",
"_cameraPointerInput.AttachRaw();", "_cameraPointerInput.AttachRaw();",
@ -73,9 +72,17 @@ public sealed class GameWindowSlice8BoundaryTests
"_uiHost.WireKeyboard(kb)", "_uiHost.WireKeyboard(kb)",
"_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(", "_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(",
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(", "_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(",
"_retainedUiGameplayBinding =",
"AcDream.App.Input.RetainedUiGameplayBinding.Create(",
"_retainedUiGameplayBinding.Attach();",
"_renderFrameOrchestrator =", "_renderFrameOrchestrator =",
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(", "_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_liveSessionHost = CreateLiveSessionHost();", "_liveSessionHost = CreateLiveSessionHost();",
"_liveCombatModeCommands.Bind(combatCommand);",
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);",
"_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(",
"_gameplayInputActions.Attach();",
"_liveSessionHost.Start(_options)"); "_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)")); Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal); Assert.Contains("if (firstKb is not null)", body, StringComparison.Ordinal);
@ -128,33 +135,53 @@ public sealed class GameWindowSlice8BoundaryTests
Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal); Assert.DoesNotContain("private void RefreshSkyForCurrentDay()", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal); Assert.DoesNotContain("private void OnEnvironChanged(", source, StringComparison.Ordinal);
string cycleTime = MethodBody( Assert.DoesNotContain("private void CycleTimeOfDay()", source, StringComparison.Ordinal);
"private void CycleTimeOfDay()", Assert.DoesNotContain("private void CycleWeather()", source, StringComparison.Ordinal);
"private void CycleWeather()");
AssertAppearsInOrder( AssertAppearsInOrder(
cycleTime, load,
"_worldEnvironment.CycleTimeOfDay();", "_debugVm.CycleTimeOfDay =",
"_debugVm?.AddToast(message);"); "_runtimeDiagnosticCommands.CycleTimeOfDay;",
"new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(",
"_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);");
} }
[Fact] [Fact]
public void InputAction_PreservesRetailAcceptedPriority() public void InputAction_IsOneTypedOwnerHandoff()
{ {
string body = MethodBody( string source = GameWindowSource();
"private void OnInputAction(", string load = MethodBody(
"private void ToggleLiveCombatMode()"); "private void OnLoad()",
"private AcDream.App.Net.LiveSessionHost");
string shutdown = MethodBody(
"private ResourceShutdownTransaction CreateShutdownTransaction()",
"private void OnFocusChanged(bool focused)");
Assert.DoesNotContain("private void OnInputAction(", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void SetInputCombatScope(", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void ToggleLiveCombatMode()", source, StringComparison.Ordinal);
Assert.DoesNotContain("private void OnUiDragReleasedOutside(", source, StringComparison.Ordinal);
Assert.DoesNotContain("DragReleasedOutsideUi +=", source, StringComparison.Ordinal);
Assert.Equal(1, CountOccurrences(
load,
"AcDream.App.Input.GameplayInputActionRouter.Create("));
Assert.Equal(1, CountOccurrences(load, "_gameplayInputActions.Attach();"));
Assert.Equal(1, CountOccurrences(
load,
"AcDream.App.Input.RetainedUiGameplayBinding.Create("));
Assert.Equal(1, CountOccurrences(
load,
"_retainedUiGameplayBinding.Attach();"));
AssertAppearsInOrder( AssertAppearsInOrder(
body, shutdown,
"HandlePointerAction(action, activation)", "_liveCombatModeCommands.Deactivate",
"action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp", "_runtimeDiagnosticCommands.Deactivate",
"HandleScrollAction(action);", "_retainedUiGameplayBinding?.Deactivate()",
"HandleCombatAction(action, activation)", "_gameplayInputActions?.Deactivate()",
"activation != AcDream.UI.Abstractions.Input.ActivationType.Press", "new ResourceShutdownStage(\"session lifetime\"",
"_retailUiRuntime?.HandleInputAction(action)", "binding.Dispose();",
"_selectionInteractions?.HandleInputAction(action)", "_retainedUiGameplayBinding = null;",
"HandlePressedMovementAction(action)", "actions.Dispose();",
"switch (action)"); "_gameplayInputActions = null;");
} }
[Fact] [Fact]
@ -265,10 +292,16 @@ public sealed class GameWindowSlice8BoundaryTests
"_windowCallbacks = null;"); "_windowCallbacks = null;");
AssertAppearsInOrder( AssertAppearsInOrder(
manifest, manifest,
"new(\"combat command slot\", _liveCombatModeCommands.Deactivate)",
"new(\"diagnostic command slot\", _runtimeDiagnosticCommands.Deactivate)",
"new(\"retained gameplay\", () => _retainedUiGameplayBinding?.Deactivate())",
"new(\"gameplay actions\", () => _gameplayInputActions?.Deactivate())",
"new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())", "new(\"camera pointer\", () => _cameraPointerInput?.Deactivate())",
"new ResourceShutdownStage(\"session lifetime\"", "new ResourceShutdownStage(\"session lifetime\"",
"controller.Dispose();", "controller.Dispose();",
"new ResourceShutdownStage(\"input callback detach\"", "new ResourceShutdownStage(\"input callback detach\"",
"binding.Dispose();",
"actions.Dispose();",
"pointer.Dispose();", "pointer.Dispose();",
"new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"session dependents\"",
"pointer.ReleaseMouseLookAfterSessionRetirement();", "pointer.ReleaseMouseLookAfterSessionRetirement();",

View file

@ -44,9 +44,11 @@ public sealed class CombatInputPlannerTests
{ {
var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0); var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0);
Assert.Equal( DefaultCombatModeDecision decision =
CombatMode.NonCombat, CombatInputPlanner.GetDefaultCombatModeDecision([held]);
CombatInputPlanner.GetDefaultCombatMode([held]));
Assert.Equal(CombatMode.NonCombat, decision.Mode);
Assert.Same(held, decision.IncompatibleHeldItem);
} }
[Fact] [Fact]
@ -64,6 +66,9 @@ public sealed class CombatInputPlannerTests
Assert.Equal( Assert.Equal(
CombatMode.Missile, CombatMode.Missile,
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile)); CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.Missile));
Assert.Equal(
CombatMode.NonCombat,
CombatInputPlanner.ToggleMode(CombatMode.NonCombat, CombatMode.NonCombat));
} }
[Fact] [Fact]
@ -71,6 +76,7 @@ public sealed class CombatInputPlannerTests
{ {
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee)); Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Melee));
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic)); Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Magic));
Assert.Equal(CombatMode.NonCombat, CombatInputPlanner.ToggleMode(CombatMode.Undef));
} }
[Theory] [Theory]