merge: integrate GameWindow stabilization through checkpoint H
This commit is contained in:
commit
c791f13853
463 changed files with 82858 additions and 16716 deletions
69
AGENTS.md
69
AGENTS.md
|
|
@ -108,19 +108,62 @@ movement queries.
|
|||
|
||||
## Current state
|
||||
|
||||
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent,
|
||||
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
|
||||
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
|
||||
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
|
||||
three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds
|
||||
(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite
|
||||
is 6,457 passed / 5 skipped. Its unattended seven-destination
|
||||
connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining
|
||||
ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
|
||||
locomotion/collision/projectile/teleport visual gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #153, #116, R6 residuals, and the deferred
|
||||
Modern Pipeline track (MP1b+).
|
||||
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
|
||||
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail
|
||||
casting, spellbook/component-book UI, active effects, recall/portal-space
|
||||
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
|
||||
and the final two-client portal-out/materialization observer flow are
|
||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests
|
||||
/ 3 skips, and 7,606 complete-solution tests / 5 skips.
|
||||
|
||||
**Active prerequisite before new M4 subsystem work:** continue the
|
||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
||||
Selection/interaction Slice 1, live-animation-presentation Slice 2, complete
|
||||
live-session ownership Slice 3, and live-entity App integration Slice 4 are
|
||||
landed. Focused hydration, teardown, inbound-authority, network-update, and
|
||||
motion-runtime owners now sit over canonical `LiveEntityRuntime` without a
|
||||
second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||
update-frame orchestration, and render-frame orchestration all have typed
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||
Slice 8 checkpoints A–H are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state.
|
||||
`CameraPointerInputController` and `FramebufferResizeController` now own raw
|
||||
camera input/focus/scroll and physical framebuffer publication. Silk,
|
||||
dispatcher, retained-UI, and devtools device edges are reversible,
|
||||
transactional, and terminal after disposal; live-session retirement precedes
|
||||
physical callback removal. `GameplayInputActionRouter` now owns the sole
|
||||
semantic action-priority
|
||||
graph; typed owners route combat, diagnostics, and remaining commands, and the
|
||||
retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines /
|
||||
198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime
|
||||
settings owner, one immutable startup snapshot, ordered one-shot startup
|
||||
application, and inert late target binding. `GameWindow` is now 3,663 raw lines /
|
||||
162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign
|
||||
baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second
|
||||
lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass.
|
||||
Checkpoint H adds sole terrain/sky render-resource roots, one retained
|
||||
Host/runtime lease, an atomic update/render frame-root pair, prepared portal
|
||||
fallback/transfer, and checked retryable GL construction/state boundaries.
|
||||
`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines
|
||||
(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice
|
||||
8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak
|
||||
snapshots, and final connected gates—remain active.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||
#116, remaining
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
|
||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||
`docs/architecture/code-structure.md`; start
|
||||
magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start
|
||||
render/streaming work at `claude-memory/project_render_pipeline_digest.md`.
|
||||
|
||||
Documentation entry point: [`docs/README.md`](docs/README.md).
|
||||
|
||||
|
|
|
|||
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -106,19 +106,62 @@ movement queries.
|
|||
|
||||
## Current state
|
||||
|
||||
**M3 — Cast a spell ACTIVE; automated implementation complete 2026-07-15.** Retail cast intent,
|
||||
component/target gates, live enchantment wire, Magic-scoped input, spell bar, spellbook, component
|
||||
book, effect indicators, shared main-panel switching, and positive/negative effects UI are implemented over the Step-9 projectile/DAT-effect
|
||||
foundation. The 2026-07-20 R6 complete-root-Frame/object-workset cutover is automated-test complete;
|
||||
three independent conformance/architecture/adversarial re-reviews are clean, the Release build succeeds
|
||||
(the clean documentation audit exposes 17 pre-existing test-project warnings, #228), and the full suite
|
||||
is 6,457 passed / 5 skipped. Its unattended seven-destination
|
||||
connected input/portal/performance rebaseline also passes without an interactive desktop. Remaining
|
||||
ownership cleanup and registered TS-50/TS-51 timing residuals stay carried. **Next:** connected R6
|
||||
locomotion/collision/projectile/teleport visual gate, then the final two-client
|
||||
portal-out/materialization observer gate. **Carried:** #153, #116, R6 residuals, and the deferred
|
||||
Modern Pipeline track (MP1b+).
|
||||
Start magic work at `claude-memory/project_magic_ui_and_casting.md`; detailed state is below.
|
||||
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail
|
||||
casting, spellbook/component-book UI, active effects, recall/portal-space
|
||||
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
|
||||
and the final two-client portal-out/materialization observer flow are
|
||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; Checkpoint H passes 61 focused ownership tests, 3,236 App tests
|
||||
/ 3 skips, and 7,606 complete-solution tests / 5 skips.
|
||||
|
||||
**Active prerequisite before new M4 subsystem work:** continue the
|
||||
behavior-preserving `GameWindow` decomposition reconciled on 2026-07-21.
|
||||
Selection/interaction Slice 1, live-animation-presentation Slice 2, complete
|
||||
live-session ownership Slice 3, and live-entity App integration Slice 4 are
|
||||
landed. Focused hydration, teardown, inbound-authority, network-update, and
|
||||
motion-runtime owners now sit over canonical `LiveEntityRuntime` without a
|
||||
second GUID dictionary. Slices 5–7 are complete: landblock presentation,
|
||||
update-frame orchestration, and render-frame orchestration all have typed
|
||||
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
|
||||
the frame owners have no direct window or anonymous callback-bag ownership.
|
||||
Slice 8 checkpoints A–H are also complete: dead host residue is removed,
|
||||
native-window callback intake has reversible lifetime ownership, and
|
||||
`LiveSessionHost` owns reset/binding composition over the sole canonical
|
||||
session controller. `WorldEnvironmentController` solely owns the clock, DAT
|
||||
sky/day group, weather, synchronization, and AdminEnvirons state.
|
||||
`CameraPointerInputController` and `FramebufferResizeController` now own raw
|
||||
camera input/focus/scroll and physical framebuffer publication. Silk,
|
||||
dispatcher, retained-UI, and devtools device edges are reversible,
|
||||
transactional, and terminal after disposal; live-session retirement precedes
|
||||
physical callback removal. `GameplayInputActionRouter` now owns the sole
|
||||
semantic action-priority
|
||||
graph; typed owners route combat, diagnostics, and remaining commands, and the
|
||||
retained-root item-drop edge is reversible. `GameWindow` was 4,057 raw lines /
|
||||
198 fields / 54 methods at F. Checkpoint G adds the sole pre-window/runtime
|
||||
settings owner, one immutable startup snapshot, ordered one-shot startup
|
||||
application, and inert late target binding. `GameWindow` is now 3,663 raw lines /
|
||||
162 fields / 37 methods, down 12,060 lines (76.7%) from the 15,723-line campaign
|
||||
baseline. The 7,553-test / 5-skip Checkpoint G Release suite, 315.6-second
|
||||
lifecycle/reconnect gate, and 395.2-second synchronized nine-stop soak pass.
|
||||
Checkpoint H adds sole terrain/sky render-resource roots, one retained
|
||||
Host/runtime lease, an atomic update/render frame-root pair, prepared portal
|
||||
fallback/transfer, and checked retryable GL construction/state boundaries.
|
||||
`GameWindow` is 3,689 raw lines / 162 fields / 37 methods, down 12,034 lines
|
||||
(76.5%) from baseline. All three final corrected-diff reviews are clean. Slice
|
||||
8 checkpoints I–L—ordered composition, retryable shutdown, canonical soak
|
||||
snapshots, and final connected gates—remain active.
|
||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
|
||||
#116, remaining
|
||||
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
|
||||
lifestone/particle alpha visual gate.
|
||||
|
||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||
`docs/architecture/code-structure.md`; start
|
||||
magic follow-up at `claude-memory/project_magic_ui_and_casting.md`; start
|
||||
render/streaming work at `claude-memory/project_render_pipeline_digest.md`.
|
||||
|
||||
Documentation entry point: [`docs/README.md`](docs/README.md).
|
||||
|
||||
|
|
|
|||
199
docs/ISSUES.md
199
docs/ISSUES.md
|
|
@ -24,16 +24,28 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## Current queue — 2026-07-20
|
||||
## Current queue — 2026-07-22
|
||||
|
||||
- **Next visual gate:** R6 locomotion/collision/projectile/teleport comparison
|
||||
after the complete-root-Frame/object-workset cutover.
|
||||
- **M3 closing gate:** `#218`, final two-client portal-out/materialization and
|
||||
indoor observer comparison.
|
||||
- **Active structural work:** `GameWindow` decomposition. Slices 1–7 are
|
||||
complete; Slice 8 checkpoints A–G now own native callbacks, live-session
|
||||
composition, world-environment state, reversible device input/raw pointer,
|
||||
physical framebuffer publication, gameplay action priority, typed commands,
|
||||
the retained-root item-drop edge, and two-phase runtime settings outside the
|
||||
shell.
|
||||
`GameWindow.OnUpdate` and `GameWindow.OnRender` are typed orchestration
|
||||
handoffs and the class is 3,663 lines / 162 fields / 37 methods. Remaining
|
||||
Slice 8 resource/composition/shutdown
|
||||
cleanup is active in
|
||||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||||
This is the behavior-preserving prerequisite before new M4 feature bodies.
|
||||
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
|
||||
snapshots to the nine-stop soak so process-residency variance cannot be
|
||||
mistaken for an owner leak or silently waved away.
|
||||
- **Separate rendering gate:** `#225`, lifestone/particle alpha ordering. Its
|
||||
connected performance, lifetime, and unattended portal routes pass.
|
||||
- **Carried behavior debt:** `#153` far-teleport unstreamed-edge arrival and
|
||||
`#116` slide response. TS-50/TS-51 are tracked in the divergence register.
|
||||
`#116` slide response. TS-50/TS-51/TS-53 are tracked in the divergence
|
||||
register.
|
||||
- **Deferred visual fidelity:** `#226` retail landscape detail overlay.
|
||||
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
|
||||
production compilation and all tests pass.
|
||||
|
|
@ -67,6 +79,164 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #233 — Live skill-credit resolver used an ACE shortcut, not retail's formula
|
||||
|
||||
**Status:** DONE (2026-07-22, GameWindow Slice 8 Checkpoint C)
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-22
|
||||
**Component:** live character state / DAT skill formulas
|
||||
|
||||
**Description:** The live skill-update route calculated a skill's primary-
|
||||
attribute contribution with an ACE-oriented shortcut. It rejected every
|
||||
formula whose first multiplier was zero, truncated division, and added `W`
|
||||
after division. Custom DAT formulas—and any stock boundary landing on a half—
|
||||
could therefore display the wrong skill value.
|
||||
|
||||
**Root cause / status:** The binding cited ACE without first checking named
|
||||
retail. `SkillFormula::Calculate @ 0x00591960` treats all four arithmetic words
|
||||
as unsigned, fails only for `Z == 0`, forms the wrapping numerator
|
||||
`X*a + Y*b + W`, and rounds `numerator/Z` half-up. Checkpoint C moved that
|
||||
algorithm into `RetailSkillFormula`, added a named `LiveSkillCreditResolver`,
|
||||
and pinned zero-X, W placement, half rounding, unsigned reinterpretation, and
|
||||
32-bit wrap with conformance tests.
|
||||
|
||||
**Files:** `src/AcDream.App/Net/RetailSkillFormula.cs`;
|
||||
`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`.
|
||||
|
||||
**Research:**
|
||||
[`docs/research/skill_formula_calculate_pseudocode.md`](research/skill_formula_calculate_pseudocode.md).
|
||||
|
||||
**Acceptance:** Live skill updates resolve the DAT entry and reproduce the
|
||||
named retail function for all unsigned field values; missing skills or
|
||||
attributes remain safely projected as zero.
|
||||
|
||||
---
|
||||
|
||||
## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-07-22
|
||||
**Component:** connected automation / resource diagnostics
|
||||
|
||||
**Description:** Identical-binary nine-stop runs can cross the same-location
|
||||
working/private-process-memory tolerance even when all deterministic lifecycle,
|
||||
entity, animation, allocation-rate, update-cost, movement, and graceful-close
|
||||
checks pass. At Slice 7 closeout, two runs reported Caul-plateau working-set
|
||||
growth of +240.9 MiB and +497.4 MiB; a third unchanged run passed at +85.7 MiB
|
||||
(private +59.3 MiB). Earlier Slice 6 development history also contains both a
|
||||
coarse-memory failure and subsequent clean runs.
|
||||
|
||||
**Root cause / status:** The current soak samples only process working set and
|
||||
private bytes plus coarse world/title counters. Those totals combine live .NET
|
||||
objects, committed-but-free GC segments, native allocations, mapped pages, and
|
||||
GPU-driver residency, so a failed total cannot identify the owner that grew.
|
||||
This is a diagnostic blind spot, not evidence that the threshold should be
|
||||
raised. The unchanged gate produced a clean required closeout pass; no runtime
|
||||
workaround or tolerance change was made.
|
||||
|
||||
**Files:** `tools/run-connected-r6-soak.ps1`;
|
||||
`src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs`;
|
||||
`src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs`.
|
||||
|
||||
**Acceptance:** Each named soak checkpoint records the existing canonical
|
||||
world-lifecycle resource snapshot (managed used/committed, tracked GPU buffers
|
||||
and textures, mesh/atlas caches, composite/particle texture ownership, VFX and
|
||||
pending teardown counts). Same-location assertions identify which owner grew;
|
||||
process residency remains a secondary guard. Repeated clean runs pass without
|
||||
loosening the canonical-owner leak limits.
|
||||
|
||||
---
|
||||
|
||||
## #231 — F-key pickup omits retail pending destination-slot presentation
|
||||
|
||||
**Status:** DONE (2026-07-21, `52dbb574` + `5acc3f01`)
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-07-21
|
||||
**Component:** selection / inventory / retained UI
|
||||
|
||||
**Description:** World double-click loot reserves its destination cell with the
|
||||
gray pending mesh before the request, but `SelectionPickUp` queues `SendPickUp`
|
||||
directly and skips that presentation. The item can therefore settle into the
|
||||
backpack without the same immediate first-slot reservation retail shows.
|
||||
|
||||
**Root cause / status:** Retail `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0`
|
||||
always sends `CM_Item::SendNotice_ShowPendingInPlayer` before
|
||||
`AttemptToPlaceInContainer`, including the F-key action from
|
||||
`CPlayerSystem::OnAction @ 0x00561964`. acdream's double-click path enters
|
||||
`ItemInteractionController` and raises `PendingBackpackPlacementRequested`;
|
||||
the F-key switch case bypasses that owner.
|
||||
|
||||
**Files:** `src/AcDream.App/UI/ItemInteractionController.cs`;
|
||||
`src/AcDream.App/Interaction/SelectionInteractionController.cs`;
|
||||
`src/AcDream.App/UI/Layout/InventoryController.cs`.
|
||||
|
||||
**Research:**
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md).
|
||||
|
||||
**Acceptance:** F-key pickup validates the target, publishes the exact
|
||||
destination/placement pending reservation, then sends one authoritative request.
|
||||
Server confirmation settles it; server failure removes it without speculative
|
||||
ownership mutation.
|
||||
|
||||
**Resolution:** `ItemInteractionController.PlaceWorldItemInBackpack` now owns
|
||||
the exact-token pending destination projection. The F-key route reserves it
|
||||
before immediate or deferred MoveTo transport, promotes that same reservation
|
||||
only when the packet is actually sent, and withdraws it on cancel/lifetime
|
||||
loss. The review correction also ported the single global
|
||||
`ACCWeenieObject::prevRequest` owner across inventory, external-container,
|
||||
toolbar, paperdoll, give, merge, split, and drop routes. Optimistic placement,
|
||||
server rollback, and authoritative response notifications are distinct; a
|
||||
response clears global and local state atomically before reentrant observers.
|
||||
|
||||
---
|
||||
|
||||
## #230 — Selection hits and deferred actions can cross live GUID incarnations
|
||||
|
||||
**Status:** DONE (2026-07-21, `047a4c83` + `5acc3f01`)
|
||||
**Severity:** HIGH
|
||||
**Filed:** 2026-07-21
|
||||
**Component:** selection / live entity lifetime / interaction
|
||||
|
||||
**Description:** The completed render-selection frame and a pending close-range
|
||||
Use/PickUp store only the server GUID. If the object hides, is deleted, or is
|
||||
replaced with the same GUID before the next pick/completion, old presentation or
|
||||
movement state can resolve to the replacement incarnation. Session reset also
|
||||
leaves item target mode and the render-selection pulse/frame outside one atomic
|
||||
interaction reset.
|
||||
|
||||
**Root cause / status:** `RetailSelectionPart`/`RetailSelectionHit`, click
|
||||
lighting, and `PendingPostArrivalAction` lack the canonical local-entity/record
|
||||
identity owned by `LiveEntityRuntime`. Teardown's GUID-only replacement guard
|
||||
correctly protects new selection, but necessarily skips clearing a GUID-only
|
||||
pending action. `CombatTargetController` also treats SessionReset like an
|
||||
ordinary clear and can re-acquire before live records drain.
|
||||
|
||||
**Files:** `src/AcDream.Core/Selection/RetailSelectionMesh.cs`;
|
||||
`src/AcDream.App/Interaction/WorldSelectionQuery.cs`;
|
||||
`src/AcDream.App/Interaction/SelectionInteractionController.cs`;
|
||||
`src/AcDream.App/Input/OutboundInteractionQueue.cs`.
|
||||
|
||||
**Research:**
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](plans/2026-07-21-gamewindow-slice-1-selection-interaction.md).
|
||||
|
||||
**Acceptance:** Hidden/pending/deleted/replaced objects reject stale render hits;
|
||||
old click lighting never colors a replacement; natural MoveTo completion sends
|
||||
only for the captured current visible incarnation; session reset clears the
|
||||
complete interaction lifetime and never auto-targets.
|
||||
|
||||
**Resolution:** Render parts/hits and SmartBox lighting now carry the local
|
||||
`WorldEntity.Id`; `LiveEntityRuntime` revalidates that identity against the
|
||||
current interaction-visible record. Deferred actions capture the same identity
|
||||
and teardown clears the captured action even after GUID replacement. Session
|
||||
reset now clears published selection geometry, lighting, ItemHolder target and
|
||||
throttle state, and cannot trigger combat auto-target acquisition. Queued
|
||||
input also captures the exact `ClientObject` reference, deferred movement is a
|
||||
cancel-before-arm transaction, and session queue epochs prevent pre-reset work
|
||||
from crossing into a new world.
|
||||
|
||||
---
|
||||
|
||||
## #228 — Clean Release build emits 17 test-project warnings
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -98,7 +268,7 @@ production runtime failure.
|
|||
|
||||
**Acceptance:** A non-incremental Release solution build reports zero warnings
|
||||
and zero errors without suppressing analyzers or weakening nullable checking;
|
||||
the full 6,457-pass / 5-skip suite remains green.
|
||||
the full 6,558-pass / 5-skip suite remains green.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -565,7 +735,7 @@ Passed 2026-07-17: “Yes works.”
|
|||
|
||||
## #218 — Portal silhouette pose, destination reveal, and indoor observer snap
|
||||
|
||||
**Status:** IN-PROGRESS — root fixes implemented 2026-07-16; pending connected visual gate
|
||||
**Status:** DONE — 2026-07-21, final two-client observer gate user-confirmed
|
||||
**Severity:** HIGH
|
||||
**Filed:** 2026-07-16
|
||||
**Component:** portal VFX / streaming / physics / outbound movement
|
||||
|
|
@ -635,6 +805,9 @@ pose; outdoor exits reveal only fully rendered terrain/scenery; and a retail
|
|||
observer sees acdream run and stop repeatedly inside a dungeon without any
|
||||
snap back to the entrance.
|
||||
|
||||
**Gate:** Passed 2026-07-21. The user confirmed the final two-client
|
||||
portal-out/materialization and indoor observer comparison on the R6 baseline.
|
||||
|
||||
---
|
||||
|
||||
## #217 — Character windows did not receive live 64-bit experience updates
|
||||
|
|
@ -7445,8 +7618,14 @@ equivalent of retail `SmartBox::UseTime` keeping position completion behind
|
|||
`CellManager::blocking_for_cells`. Both paths invalidate destination texture
|
||||
readiness, prepare composites after mesh publication, and require the same
|
||||
render-plus-collision predicate. Focused login/barrier tests and the complete
|
||||
Release suite pass; capped connected visual verification remains the final
|
||||
gate.
|
||||
Release suite pass. The user confirmed the capped first-login reveal, and the
|
||||
follow-up deterministic connected gate now repeats fresh login plus five
|
||||
portal/dungeon transitions, returns to the same outdoor location, closes with
|
||||
the retail character-logoff/transport-disconnect handshake, and reconnects in
|
||||
a fresh uncapped process. All six saved PNGs show complete geometry; all
|
||||
structured checkpoints report ready render/composite/collision domains and
|
||||
zero reveal invariant failures or pending teardown/upload/warmup work. See
|
||||
`docs/plans/2026-07-20-automated-world-lifecycle-gate.md`.
|
||||
|
||||
**Research:**
|
||||
`docs/research/2026-07-16-portal-completion-pseudocode.md` §2.
|
||||
|
|
|
|||
|
|
@ -4,23 +4,27 @@ This page is the entry point for project documentation. It distinguishes
|
|||
current sources of truth from implementation history so an old plan or issue
|
||||
banner cannot silently override the current program state.
|
||||
|
||||
## Current snapshot — 2026-07-20
|
||||
## Current snapshot — 2026-07-21
|
||||
|
||||
- **Active milestone:** M3, “Cast a spell.” The automated implementation and
|
||||
connected single-client magic/portal presentation gates are complete.
|
||||
- **Next visual gates:** the R6 locomotion/collision/projectile/teleport retail
|
||||
comparison, then the final two-client portal-out/materialization observer
|
||||
comparison (`#218`).
|
||||
- **Milestone state:** M3, “Cast a spell,” landed 2026-07-21. M4, “Live in the
|
||||
world,” is next.
|
||||
- **Active program:** behavior-preserving `GameWindow` decomposition before
|
||||
new M4 subsystem work. The reconciled ownership ledger and slice order are
|
||||
in [`architecture/code-structure.md`](architecture/code-structure.md).
|
||||
Selection/interaction Slice 1 is complete; live animation presentation is
|
||||
next.
|
||||
- **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and
|
||||
the final two-client portal-out/materialization observer comparison (`#218`).
|
||||
- **Separate visual verification:** the shared-alpha lifestone/particle result
|
||||
in `#225`; its connected resource-lifetime and performance routes pass.
|
||||
- **Carried behavior debt:** `#153` (far teleport onto an unstreamed edge),
|
||||
`#116` (two narrowed slide-response cases), and divergence rows TS-50/TS-51.
|
||||
- **Deferred engineering tracks:** Modern Pipeline MP1b+ and Linux/headless
|
||||
automation (Track LH). Neither is part of the active M3 gate.
|
||||
automation (Track LH). Neither is part of the active structural campaign.
|
||||
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
|
||||
and UN 5 — plus the retained retired TS-37 history note.
|
||||
- **Latest automated baseline:** Release build succeeds with 17 known
|
||||
test-project warnings (`#228`); 6,457 tests passed and 5 intentionally
|
||||
test-project warnings (`#228`); 6,558 tests passed and 5 intentionally
|
||||
skipped. The unattended connected R6 route
|
||||
completed seven portal materializations, production input exercises, and a
|
||||
graceful close. See
|
||||
|
|
|
|||
|
|
@ -239,7 +239,8 @@ src/
|
|||
Streaming/
|
||||
StreamingController.cs -> done
|
||||
GpuWorldState.cs -> done
|
||||
WorldRevealReadinessBarrier.cs -> shared login/portal reveal owner
|
||||
WorldRevealCoordinator.cs -> shared login/portal lifetime owner
|
||||
WorldRevealReadinessBarrier.cs -> canonical destination predicate
|
||||
Input/
|
||||
PlayerMovementController.cs -> active movement driver
|
||||
Plugins/
|
||||
|
|
@ -604,89 +605,77 @@ visual-distance reduction.
|
|||
## Per-Frame Update Order (current runtime)
|
||||
|
||||
```
|
||||
1. Network tick
|
||||
└── Drain inbound queue → process CreateObject, UpdateMotion,
|
||||
UpdatePosition, PlayerTeleport → create/update GameEntities
|
||||
|
||||
2. Streaming tick
|
||||
└── Compute observer position → load/unload landblocks →
|
||||
create terrain + scenery GameEntities
|
||||
|
||||
3. Input tick (player mode only)
|
||||
└── InputDispatcher scopes → PlayerMovementController →
|
||||
MotionInterpreter/body prediction → ResolveWithTransition →
|
||||
send MoveToState/AutonomousPosition to server
|
||||
|
||||
4. Entity / animation tick
|
||||
└── Current code still has scattered world/entity state. L.1 owns
|
||||
animation parity; L.2 owns movement/collision conformance.
|
||||
|
||||
5. Render tick
|
||||
└── Read current entity mesh refs, draw
|
||||
TerrainModernRenderer + WbDrawDispatcher + EnvCellRenderer
|
||||
(frustum cull, translucency pass, portal visibility, etc.)
|
||||
World Wb translucents + Scene particles submit to RetailAlphaQueue;
|
||||
RetailPViewRenderer flushes the landscape scope before the conditional
|
||||
depth clear and GameWindow flushes the final scope before private
|
||||
viewports/UI. Sky/private viewport particles remain immediate.
|
||||
Projectiles remain ordinary live-entity draws; there is no global or
|
||||
projectile-specific render pass.
|
||||
|
||||
6. Plugin tick
|
||||
└── Fire IEvents, drain IActions queue
|
||||
|
||||
6a. UI tick
|
||||
IPanelHost.Draw → iterate registered IPanel instances, build
|
||||
ViewModels from IGameState, dispatch user Commands via ICommandBus.
|
||||
Backend-agnostic — ImGui or custom retail-look draws here depending
|
||||
on which is compiled in. See docs/plans/2026-04-24-ui-framework.md.
|
||||
UpdateFrameOrchestrator
|
||||
1. retry retained live-entity teardown tombstones
|
||||
2. normalize/publish the host and PhysicsScript clocks
|
||||
3. converge streaming origin, readiness, residence, and rescued projections
|
||||
4. sample semantic input, raw mouse, and combat intent
|
||||
5. advance local/ordinary/static live objects and deferred effect tails
|
||||
6. drain inbound session traffic in one GpuWorldState mutation batch
|
||||
7. run the local post-network CommandInterpreter position tail
|
||||
8. reconcile roots, children, emitters, and lights without advancing time
|
||||
9. expire liveness
|
||||
10. advance local teleport/reveal/tunnel presentation
|
||||
11. evaluate one-shot player-mode entry
|
||||
12. publish fly/chase/player camera presentation
|
||||
```
|
||||
|
||||
`GameWindow.OnUpdate` owns only the profiler scope and one orchestrator handoff.
|
||||
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
|
||||
`memory/project_gamewindow_decomposition.md`.
|
||||
|
||||
`GameWindow.OnRender` likewise owns only one handoff to the shipped
|
||||
`RenderFrameOrchestrator`. Its frozen graph begins per-resource frames and
|
||||
render-thread uploads; draws world/PView and its two shared-alpha scopes;
|
||||
renders portal and paperdoll private viewports, retained gameplay UI, ImGui
|
||||
devtools, and screenshots; then closes the GPU-flight transaction in
|
||||
`finally`. Both UI stacks coexist in the same frame.
|
||||
|
||||
---
|
||||
|
||||
## Render Pipeline (SSOT — current state + unified-PView target)
|
||||
## Render Pipeline (SSOT — current accepted state)
|
||||
|
||||
> The modern path (Phase N.5, mandatory) is
|
||||
> `WbDrawDispatcher` (entities) + `EnvCellRenderer` (indoor cell shells) +
|
||||
> `TerrainModernRenderer` (terrain), fed by the portal-visibility stack. This section is
|
||||
> the authoritative description of how indoor/outdoor rendering is *supposed* to work and
|
||||
> where the code currently diverges. Canonical reset handoff:
|
||||
> `docs/research/2026-05-31-render-architecture-reset-handoff.md`.
|
||||
> `TerrainModernRenderer` (terrain), fed by the unified PView stack. This is the
|
||||
> authoritative current draw model; the 2026-05-31 reset handoff is historical.
|
||||
|
||||
**The principle (retail PView).** acdream must render the world the way retail does —
|
||||
through **one** portal-visibility traversal whose output **gates every geometry type
|
||||
uniformly**. From the player's cell, walk the portal graph; each visible cell carries a
|
||||
screen-space clip region (its portal opening, recursively intersected); the **outside**
|
||||
(terrain + outdoor scenery) is reached only through **exit portals** and is clipped to
|
||||
those openings. Interior cell shells, interior statics, and the outside are **all**
|
||||
clipped to their PView region. This is why retail is **seamless by construction**. Decomp
|
||||
anchors: `PView::ConstructView` (`:433750`), `InitCell` (`:432896`), `DrawCells`
|
||||
(`:432715`), `CEnvCell::find_visible_child_cell` (`:311397`), `SmartBox::update_viewer`
|
||||
(`:92761`). Reference port acdream owns but never invokes: WB `RenderInsideOut` /
|
||||
`VisibilityManager`.
|
||||
**One visibility owner.** `RetailPViewRenderer.DrawInside` is the production
|
||||
world gate. Its root is the collided camera/viewer cell, or the synthetic
|
||||
outdoor cell adaptation; the player's current cell separately owns sunlight
|
||||
and indoor lighting. A null root exists only for login/debug/streaming-gap
|
||||
fallback frames. `RetailPViewRenderer` is the one authoritative PView owner and
|
||||
product family: it builds the main frame, deliberate per-building exterior
|
||||
floods for the synthetic outdoor root, and separate interior-root look-in
|
||||
frames. No second per-frame ACME visibility BFS competes with that family.
|
||||
|
||||
**The one rule:** *compute visibility once; enforce it once, for all geometry.* Indoors,
|
||||
you see the outside **only** through portal openings (clipped); an empty outside-view
|
||||
(windowless interior) draws **no** outdoor geometry. Outdoors, the gate is "everything."
|
||||
**Current draw discipline.** Outside-view slices draw sky, terrain, and outdoor
|
||||
statics first. Interior-root building look-ins then punch all entry apertures
|
||||
before drawing their shell and contents. The landscape shared-alpha scope
|
||||
flushes before the root-specific depth boundary. Interior roots perform the
|
||||
conditional depth clear and then write true-depth exit seals; the synthetic
|
||||
outdoor root retains world depth and writes far-Z building-entry punches.
|
||||
Opaque EnvCell shells, immediate far-to-near transparent EnvCell shells, cell
|
||||
statics and their particles, then the surviving main-stage dynamics and their
|
||||
particles follow. Look-in and outside-stage dynamics intentionally draw in
|
||||
their earlier landscape phases. The final world shared-alpha scope flushes
|
||||
before private portal/paperdoll viewports and UI.
|
||||
|
||||
**Current divergence (the patchwork — what the reset must fix).** acdream computes the
|
||||
visibility correctly (`CellVisibility.ComputeVisibility` → `PortalVisibilityBuilder.Build`,
|
||||
a `ConstructView` port → `ClipFrameAssembler`) but then **enforces it three different,
|
||||
inconsistent ways**:
|
||||
1. `TerrainModernRenderer` — gated by `TerrainClipMode {Skip|Scissor|Planes}` (the Scissor
|
||||
fallback over-includes).
|
||||
2. `EnvCellRenderer` — gated by the per-cell clip slot (≈correct; the shells DO render —
|
||||
proven by the `[shell]` probe, `ACDREAM_PROBE_SHELL`).
|
||||
3. `WbDrawDispatcher` — gated by `ParentCellId ∈ visibleCellIds`, **but outdoor stabs
|
||||
(`ParentCellId==null`) bypass the gate** → outdoor scenery/terrain shows from inside
|
||||
(issue #78).
|
||||
The modern renderer intentionally does not hard-clip every shell or entity to
|
||||
the accumulated polygon. It combines PView admission and viewcone checks with
|
||||
retail's punch/seal depth discipline; terrain/outside slices use the bounded
|
||||
clip-plane/scissor adaptation. World Wb translucents and Scene particles share
|
||||
one stable far-to-near `RetailAlphaQueue`; EnvCell transparent shells and
|
||||
private viewports remain immediate. Projectiles are ordinary live-entity draws,
|
||||
never a separate global pass.
|
||||
|
||||
Three gates that must agree but don't → structural seams (transparent walls,
|
||||
terrain-through-floor, grey enclosure). **The reset consolidates them into the single
|
||||
PView gate** (outside content clipped to the `OutsideView` region; no `ParentCellId==null`
|
||||
bypass; no Scissor over-include). This is a **consolidation of existing machinery**
|
||||
(`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case
|
||||
gate to mask a seam — that anti-pattern produced the patchwork.
|
||||
Retail anchors are `SmartBox::RenderNormalMode @ 0x00453AA0`,
|
||||
`PView::DrawInside @ 0x005A5860`, `PView::DrawCells @ 0x005A4840`,
|
||||
`LScape::draw @ 0x00506330`, `D3DPolyRender::FlushAlphaList @ 0x0059D2E0`,
|
||||
and `SceneTool::EndFrame @ 0x0043FB30`. The deliberate modern/PView
|
||||
adaptations are audited in the retail divergence register; Slice 7 changes
|
||||
their ownership only, not their behavior.
|
||||
|
||||
### Streaming publication ownership
|
||||
|
||||
|
|
@ -708,17 +697,29 @@ partially hydrated cell membership or shell placement to the renderer.
|
|||
|
||||
### World-reveal readiness ownership
|
||||
|
||||
`WorldRevealReadinessBarrier` is the single App-layer owner of the edge that
|
||||
allows normal world geometry to become visible at initial login or after a
|
||||
portal. It joins `StreamingController`/`GpuWorldState` static-mesh publication,
|
||||
`WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination
|
||||
residency. Login's sky-only gate and portal-space transit consume the same
|
||||
predicate; they differ only in presentation. Composite preparation advances
|
||||
after `WbMeshAdapter.Tick` on the render thread, never from the streaming
|
||||
worker. This is acdream's asynchronous equivalent of retail
|
||||
`WorldRevealCoordinator` is the single App-layer owner of each login or portal
|
||||
reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with
|
||||
generation-scoped lifecycle observation, so begin, preparation, readiness,
|
||||
materialization, world visibility, completion, and cancellation cannot be
|
||||
wired independently. The barrier joins `StreamingController`/`GpuWorldState`
|
||||
static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and
|
||||
`PhysicsEngine` destination residency. Login's sky-only gate and portal-space
|
||||
transit consume the same predicate; they differ only in presentation.
|
||||
Composite preparation advances after `WbMeshAdapter.Tick` on the render
|
||||
thread, never from the streaming worker. This is acdream's asynchronous equivalent of retail
|
||||
`SmartBox::UseTime` holding position completion while
|
||||
`CellManager::blocking_for_cells` is set.
|
||||
|
||||
`WorldRevealLifecycleTelemetry` is an observation seam owned by the
|
||||
coordinator, not a second lifecycle state machine. It records generation-scoped
|
||||
edges and rejects a normal-world viewport observed before canonical readiness.
|
||||
`WorldLifecycleAutomationController` consumes only coordinator snapshots plus
|
||||
resource counters; `FrameScreenshotController` performs the requested
|
||||
default-framebuffer readback after world and retained UI draw on the render
|
||||
thread. The external connected gate launches normal Release processes and
|
||||
observes these artifacts, but never mutates streaming, physics, GL, or entity
|
||||
state from a worker thread.
|
||||
|
||||
### Teleport cell identity at the streaming boundary
|
||||
|
||||
The network and physics layers carry retail's complete `Position`: an
|
||||
|
|
@ -816,12 +817,37 @@ Current movement/collision ownership:
|
|||
- **G.3 is shipped**: dungeon streaming, portal-space presentation, collision,
|
||||
lighting, doors, and the full round-trip gate landed. The separate far-
|
||||
teleport unstreamed-edge residual is issue #153, not unfinished G.3.
|
||||
- **M3** owns current connected magic/portal acceptance; Track LH is the future
|
||||
presentation-independent Linux/headless extraction.
|
||||
- **M3 landed 2026-07-21** with connected magic/portal acceptance and the final
|
||||
two-client observer gate; Track LH is the future presentation-independent
|
||||
Linux/headless extraction.
|
||||
- **The behavior-preserving thin-`GameWindow` ownership campaign is the active
|
||||
prerequisite before new M4 subsystem bodies.** Selection/interaction Slice 1
|
||||
landed with `WorldSelectionQuery`, `SelectionInteractionController`, and the
|
||||
shared retail inventory-request owner; Slice 2 moved final animation
|
||||
presentation into `LiveEntityAnimationPresenter`; Slice 3 moved the complete
|
||||
session generation into `LiveSessionController` plus its focused host and
|
||||
routers; Slice 4 moved hydration, teardown, inbound authority, network
|
||||
updates, and reusable motion-runtime policy over canonical
|
||||
`LiveEntityRuntime`. Slices 5–7 complete landblock presentation and the
|
||||
update/render frame graphs. Slice 8 checkpoints A–H now own the native
|
||||
callback shell, live-session composition, world environment, reversible
|
||||
input/pointer lifetime, physical framebuffer publication, the sole gameplay
|
||||
action-priority graph, typed commands, the retained-root item-drop edge, and
|
||||
two-phase runtime settings, sole terrain/sky render-resource lifetime,
|
||||
retained Host/runtime lease, atomic frame-root pair, and prepared portal
|
||||
fallback/transfer. `RuntimeSettingsController` is the sole settings
|
||||
persistence/current-state owner, supplies the immutable pre-window snapshot,
|
||||
applies startup pacing/display/FOV/audio once, and later borrows complete
|
||||
runtime targets without replay. Checked GL construction/state boundaries
|
||||
retain exact cleanup obligations across failure. Ordered production
|
||||
composition is the next checkpoint. The reconciled ledger and sequence live in
|
||||
`docs/architecture/code-structure.md`. This decision does not reclassify the
|
||||
remaining L.2 conformance work or Track LH.
|
||||
|
||||
The GameEntity / thin GameWindow refactor remains a valid target architecture,
|
||||
but it is not a prerequisite for L.2. Do not resurrect old R1-R8 phase numbers
|
||||
for new work; add or update roadmap phases instead.
|
||||
Full `GameEntity` type aggregation remains a separate, higher-risk target after
|
||||
the thin-`GameWindow` campaign. The current campaign reuses canonical
|
||||
`LiveEntityRuntime` rather than introducing another entity owner. Do not
|
||||
resurrect old R1-R8 phase numbers for new work; update the roadmap instead.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
# acdream — code structure & extraction sequence
|
||||
|
||||
**Status:** Living document. Created 2026-05-16 as the companion to the
|
||||
"Code Structure Rules" section in `CLAUDE.md`.
|
||||
**Status:** Living document. Created 2026-05-16; implementation reconciliation
|
||||
completed 2026-07-21; Slices 1–7 landed by 2026-07-22. The final Slice 8
|
||||
composition/shutdown cleanup is active before new M4 subsystems enter the App
|
||||
layer.
|
||||
**Purpose:** Describe the desired structural state of the App layer,
|
||||
explain the rules we've adopted, and lay out the safe extraction
|
||||
sequence from today's reality (one 14,557-line `GameWindow.cs` at the
|
||||
2026-07-20 audit) to the
|
||||
sequence from today's reality (one 15,723-line `GameWindow.cs` at the
|
||||
2026-07-21 audit) to the
|
||||
target (thin `GameWindow`, small focused collaborators).
|
||||
**Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
|
||||
(the layered architecture) and
|
||||
|
|
@ -21,30 +23,47 @@ layer is wire-compatible, the UI has a stable contract, plugins load.
|
|||
The structural debt is concentrated in **one file**:
|
||||
|
||||
```
|
||||
src/AcDream.App/Rendering/GameWindow.cs 14,557 lines (2026-07-20)
|
||||
baseline cf50ee3d 15,723 lines / 278 fields / 205 methods
|
||||
after Slice 1 14,912 lines / 278 fields / 191 methods
|
||||
after Slice 2 14,546 lines / 277 fields / 190 methods
|
||||
after Slice 3 14,310 lines / 274 fields / 190 methods
|
||||
after Slice 4 10,301 lines / 267 fields / 163 methods
|
||||
after Slice 5 closeout 8,811 lines / 247 fields / 153 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 8 checkpoint D 4,330 lines / 192 fields / 67 methods
|
||||
after Slice 8 checkpoint E 4,266 lines / 194 fields / 65 methods
|
||||
after Slice 8 checkpoint F 4,057 lines / 198 fields / 54 methods
|
||||
after Slice 8 checkpoint G 3,663 lines / 162 fields / 37 methods
|
||||
```
|
||||
|
||||
`GameWindow` is the single object that:
|
||||
|
||||
- Owns the GL context, the window, input, and shaders.
|
||||
- Reads ~40 different environment variables across its lifetime.
|
||||
- Hosts the live network session (`WorldSession`) and the offline
|
||||
pre-login state.
|
||||
- Owns parallel dictionaries for entity lookup (`_entitiesByServerGuid`,
|
||||
the per-landblock entity lists in `GpuWorldState`, plus the player
|
||||
controller's own state).
|
||||
- Drives selection / interaction (`WorldPicker`, `SendUse`,
|
||||
`SendPickUp`).
|
||||
- Composes the shipped `LiveSessionController`/host/router boundary; it no
|
||||
longer owns a parallel session, command bus, subscription lifetime, connect,
|
||||
character-entry, reconnect, or graceful-close body.
|
||||
- Owns the adapters that hydrate canonical `LiveEntityRuntime` records into
|
||||
animation, collision, rendering, and DAT-backed appearance resources.
|
||||
- Composes the shipped `WorldSelectionQuery` and
|
||||
`SelectionInteractionController`; it no longer owns world-picking,
|
||||
selection intent, Use/PickUp, or auto-walk deferral algorithms.
|
||||
- Drives per-frame render orchestration (sky → terrain → opaque mesh →
|
||||
transparent mesh → particles → debug lines → UI).
|
||||
- Builds and applies streamed landblock presentation (DAT decode, scenery,
|
||||
EnvCells, mesh publication, collision, and retirement) around the shipped
|
||||
`StreamingController` and `GpuWorldState` owners.
|
||||
- Wires up every plugin hook sink, every diagnostic, every panel.
|
||||
|
||||
Almost every M1 / M2 bug touches this file. Every new feature adds a
|
||||
field plus a method plus a wiring call. It is not getting better on its
|
||||
own.
|
||||
The extracted controllers are valuable and tested, but line count alone proves
|
||||
that creating collaborators has not yet made the window thin. A collaborator
|
||||
is a completed extraction only when it owns the state and behavior body;
|
||||
wrapping a `GameWindow` method in a delegate preserves ordering but remains a
|
||||
partial extraction.
|
||||
|
||||
The fix is **not** "rewrite `GameWindow` in one pass" — that's a
|
||||
high-risk change that would block M2. The fix is to **extract one
|
||||
The fix is **not** "rewrite `GameWindow` in one pass" — that is a
|
||||
high-risk change. The fix is to **extract one
|
||||
collaborator at a time**, verify behavior is unchanged, ship, and
|
||||
move on. This document defines that sequence.
|
||||
|
||||
|
|
@ -154,6 +173,46 @@ App currently wires it.
|
|||
|
||||
---
|
||||
|
||||
### Render-frame ownership versus recursive reachability
|
||||
|
||||
The typed `RenderFrameOrchestrator` graph owns frame sequencing and borrows
|
||||
long-lived runtime/presentation owners. The enforceable ownership invariant is
|
||||
that the orchestrator and its immediate phase owners retain neither
|
||||
`GameWindow`, an anonymous callback bag, nor direct window delegates. It is
|
||||
**not** an invariant that recursively walking every borrowed canonical owner
|
||||
can never reach an event subscriber or callback that was composed by
|
||||
`GameWindow`; that claim would be false and would confuse object-graph
|
||||
reachability with frame ownership.
|
||||
|
||||
Known recursive callback-bearing paths include (this is evidence and design
|
||||
documentation, not an exhaustive allowlist):
|
||||
|
||||
- `RetainedGameplayUiFrame -> RetailUiRuntime -> RetailUiRuntimeBindings` for
|
||||
retained gameplay UI state and commands.
|
||||
- `PrivatePresentationRenderer -> PaperdollFramePresenter ->
|
||||
RetailPaperdollFrameView/PaperdollInventoryVisibility -> UiElement tree` for
|
||||
private paperdoll visibility and texture publication. This is a second,
|
||||
independent retained-UI entry because `UiElement.Parent` links back to the
|
||||
shared root and sibling controls.
|
||||
- `DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings` for
|
||||
the optional developer UI.
|
||||
- `WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview ->
|
||||
IRuntimeSettingsPreviewSource -> RuntimeSettingsController -> optional
|
||||
SettingsVM` for the live settings draft preview applied before world drawing.
|
||||
- `LocalPlayerPortalViewport -> LocalPlayerTeleportController ->
|
||||
GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow` for the
|
||||
canonical portal/input lifetime and the host's input-action subscription.
|
||||
|
||||
These are presentation state/command seams, not alternative owners of the
|
||||
window or frame transaction. The screenshot path is deliberately not on this
|
||||
list: viewport width/height now travel in `RenderFrameInput`, so
|
||||
`FrameScreenshotController` no longer stores a window-size callback. New
|
||||
immediate phase owners must still satisfy the direct ownership invariant;
|
||||
recursive shared-owner paths are reviewed against their canonical subsystem
|
||||
lifetime rather than an impossible global no-callback assertion.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target structure of the App layer
|
||||
|
||||
The end state — not what we're shipping in one pass, but the shape
|
||||
|
|
@ -165,8 +224,9 @@ src/AcDream.App/
|
|||
├── RuntimeOptions.cs # typed startup options (Rule 4)
|
||||
├── Rendering/
|
||||
│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
|
||||
│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI)
|
||||
│ ├── RenderFrameOrchestrator.cs # GPU-flight boundary + typed world/private/UI render phases
|
||||
│ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset
|
||||
│ ├── LiveEntityAnimationPresenter.cs # final part-pose/mesh/effect composition after scheduler output
|
||||
│ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset
|
||||
│ ├── StaticLiveRootCommitter.cs # live static root → pose + collision boundary
|
||||
│ ├── TerrainModernRenderer.cs # (already exists)
|
||||
|
|
@ -176,11 +236,19 @@ src/AcDream.App/
|
|||
│ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction
|
||||
│ └── Vfx/ # (already exists)
|
||||
├── Net/
|
||||
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
|
||||
│ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle
|
||||
│ ├── LiveSessionHost.cs # App reset/selection/entry/route-factory composition over the canonical controller
|
||||
│ ├── LiveSessionLifecycleHost.cs # exact borrowed-session attachment guard
|
||||
│ ├── RetailSkillFormula.cs # named-retail unsigned skill-credit calculation + DAT lookup
|
||||
│ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers
|
||||
├── Physics/
|
||||
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
|
||||
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
|
||||
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
|
||||
│ ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration
|
||||
│ ├── LiveEntityInboundAuthorityGate.cs # exact per-channel timestamp/version admission
|
||||
│ ├── LiveEntityMotionRuntimeController.cs # shared host/setup/MoveTo/Sticky runtime policy
|
||||
│ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge
|
||||
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
|
||||
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
|
||||
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
|
||||
|
|
@ -189,13 +257,19 @@ src/AcDream.App/
|
|||
├── World/
|
||||
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
|
||||
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
|
||||
│ ├── LiveEntityHydrationController.cs # spawn/appearance/parent/delete resource integration
|
||||
│ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier
|
||||
│ ├── RetailLiveFrameCoordinator.cs # shipped: object/network/command/reconcile phase order
|
||||
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
|
||||
│ ├── WorldEnvironmentController.cs # one-shot clock/DAT-sky/day/weather/AdminEnvirons owner
|
||||
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
|
||||
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
|
||||
├── Interaction/
|
||||
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
|
||||
├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
|
||||
│ ├── WorldSelectionQuery.cs # read-only picking/classification/description queries
|
||||
│ └── SelectionInteractionController.cs # owns selection intents, Use/PickUp, auto-walk deferral
|
||||
├── Streaming/
|
||||
│ ├── LandblockPresentationPipeline.cs # DAT build/apply/retire transaction and resource publication
|
||||
│ └── ... # shipped streamer/world-state/reveal owners
|
||||
├── Input/ # (already exists)
|
||||
├── Audio/ # (already exists)
|
||||
└── Plugins/ # (already exists)
|
||||
|
|
@ -206,22 +280,27 @@ What `GameWindow` keeps:
|
|||
- `IWindow` / `GL` / `IInputContext` lifecycle (constructor + `OnLoad` +
|
||||
`Run` + `OnClosing`).
|
||||
- `RuntimeOptions` reference (the typed startup config).
|
||||
- One field per collaborator (`_liveSessionController`,
|
||||
`_liveEntityRuntime`, `_selectionInteraction`,
|
||||
`_renderFrameOrchestrator`).
|
||||
- GL resource construction and top-level collaborator composition. Construction
|
||||
is allowed here; feature algorithms and mutable subsystem state are not.
|
||||
- One field per top-level collaborator (`_liveSessionController`,
|
||||
`_liveEntityRuntime`, `_selectionInteraction`, `_streamingPresentation`,
|
||||
`_liveObjectFrame`, `_renderFrameOrchestrator`).
|
||||
- The Silk.NET event-handler stubs that delegate to collaborators.
|
||||
|
||||
What `GameWindow` loses:
|
||||
|
||||
- The 7 startup-time env var fields → moved into `RuntimeOptions`.
|
||||
- `TryStartLiveSession` + the post-login network drain → moved into
|
||||
`LiveSessionController`.
|
||||
- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
|
||||
translation → moved into `LiveEntityRuntime`.
|
||||
- `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved
|
||||
into `SelectionInteractionController`; Core `SelectionState` remains the
|
||||
already-shipped session owner and is injected into that controller.
|
||||
- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
|
||||
- Live connect/enter/logout and event-subscription bodies → completed inside
|
||||
`LiveSessionController` / `LiveSessionEventRouter`.
|
||||
- Live-object hydration adapters and final animated-part presentation → focused
|
||||
world/render owners over canonical `LiveEntityRuntime` records.
|
||||
- `WorldPicker`, target queries, and selection-driven Use/PickUp/auto-walk →
|
||||
`WorldSelectionQuery` + `SelectionInteractionController`. Core
|
||||
`SelectionState` remains the injected session owner.
|
||||
- Landblock DAT build/apply/retirement presentation →
|
||||
`LandblockPresentationPipeline`; `StreamingController` remains the residency
|
||||
scheduler and `GpuWorldState` remains the spatial registry.
|
||||
- Per-frame draw orchestration and its frame-local scratch state →
|
||||
`RenderFrameOrchestrator`.
|
||||
|
||||
The eventual `GameEntity` aggregation (target state described in
|
||||
`acdream-architecture.md` §"GameEntity: The Unified Entity") happens
|
||||
|
|
@ -247,10 +326,14 @@ allocators fail fast instead of wrapping into another landblock's namespace.
|
|||
All other non-Parent packet
|
||||
families still need the future general queue tracked by divergence AD-32.
|
||||
|
||||
The per-frame object body is no longer an animation-dictionary loop inside
|
||||
`GameWindow`. `LiveEntityAnimationScheduler` snapshots canonical spatial root
|
||||
records and advances the incarnation-stable object clock, PartArray, hooks,
|
||||
one selected movement owner, and manager tail in retail order. Manager-less
|
||||
The per-frame object scheduler is extracted, but final animated-part
|
||||
presentation is not. `LiveEntityAnimationScheduler` snapshots canonical spatial
|
||||
root records and advances the incarnation-stable object clock, PartArray,
|
||||
hooks, one selected movement owner, and manager tail in retail order. The
|
||||
remaining `GameWindow.TickAnimations` loop still composes part transforms,
|
||||
drawable `MeshRef`s, and effect poses from the scheduler output; moving that
|
||||
body into `LiveEntityAnimationPresenter` is an explicit pending slice.
|
||||
Manager-less
|
||||
bodies delegate their candidate/Transition/cell/shadow commit to
|
||||
`LiveEntityOrdinaryPhysicsUpdater`; retained projectile bodies and remote
|
||||
MovementManagers remain mutually exclusive movement owners. Static animation
|
||||
|
|
@ -336,145 +419,341 @@ strand teleport, movement, shadow, light, or GUID-scoped state.
|
|||
|
||||
---
|
||||
|
||||
## 4. Extraction sequence — safest first
|
||||
## 4. Reconciled ownership ledger — 2026-07-21
|
||||
|
||||
Each step is **one PR-sized refactor**. Each must build clean, all
|
||||
tests pass, and visual verification at Holtburg looks identical to
|
||||
the previous step. Don't bundle two steps.
|
||||
This section replaces the original six-step sketch. That sketch correctly
|
||||
identified the target but overstated some extractions and omitted large bodies
|
||||
added during M2/M3. The current audit is based on implementation ownership, not
|
||||
whether a class with the planned name exists.
|
||||
|
||||
### Step 1 — `RuntimeOptions` — SHIPPED (`eda936dc`)
|
||||
### 4.1 What counts as extracted
|
||||
|
||||
**Scope:** Replace startup-time env var reads with a typed options
|
||||
object built once in `Program.cs`.
|
||||
An extraction is **complete** only when:
|
||||
|
||||
**Behavior change:** None. Same env vars, same defaults, same effects.
|
||||
1. the collaborator owns the mutable state and behavior body;
|
||||
2. `GameWindow` constructs it and delegates through a narrow method;
|
||||
3. the collaborator does not call back into arbitrary `GameWindow` methods;
|
||||
4. focused tests exercise the collaborator without constructing a Silk window;
|
||||
5. the accepted connected/visual behavior is unchanged.
|
||||
|
||||
**Risk:** Low. Mechanical substitution at ~10-15 call sites in
|
||||
`GameWindow.cs` + one constructor signature change.
|
||||
A class that stores delegates back to substantial `GameWindow` methods is a
|
||||
useful ordering seam, but its ownership status is **partial**.
|
||||
|
||||
**Test:** Unit tests for `RuntimeOptions.FromEnvironment` parsing (the
|
||||
new `tests/AcDream.App.Tests/` project).
|
||||
### 4.2 Current implementation truth
|
||||
|
||||
**Verification:** `dotnet build` + `dotnet test` green. Visual launch
|
||||
verifies live mode + dat dir resolution still work.
|
||||
| Area | Status | Current truth |
|
||||
|---|---|---|
|
||||
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
|
||||
| Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. |
|
||||
| World environment | **Complete ownership** | `WorldEnvironmentController` solely owns WorldTime, the loaded DAT sky, current day group, Weather, server synchronization, AdminEnvirons bridging, and debug time/weather cycles. `GameWindow` keeps ABI-compatible readonly aliases to the same clock/weather instances and composes one render source plus the two live-session callbacks. TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. |
|
||||
| Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). |
|
||||
| Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). |
|
||||
| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. |
|
||||
| Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. |
|
||||
| Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. |
|
||||
| Landblock presentation | **Complete** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`, closeout `4a205a3e`). |
|
||||
| Render-frame orchestration | **Complete** | `RenderFrameOrchestrator` owns the GPU-flight, resource, world/PView/shared-alpha, private-presentation, diagnostics, screenshot, and recovery graph. `GameWindow.OnRender` takes one logical window-size snapshot and performs one immutable handoff (`9d7df1bf`). |
|
||||
| Unified `GameEntity` | **Deferred target** | `LiveEntityRuntime` already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make `GameWindow` thin. |
|
||||
|
||||
### Step 2 — `LiveSessionController` — SHIPPED (`0b25df53`)
|
||||
### 4.3 Revised extraction sequence
|
||||
|
||||
**Scope:** Extract `TryStartLiveSession` + the WorldSession ownership +
|
||||
the post-EnterWorld drain (`OnLiveStateUpdated`, `OnLiveEntityDeleted`,
|
||||
etc.) into a controller class.
|
||||
Every numbered slice is behavior-preserving and independently committed. A
|
||||
slice does not include feature work or opportunistic gameplay fixes.
|
||||
|
||||
**Behavior change:** None. Same wire behavior, same handshake.
|
||||
#### Gate 0 — deterministic baseline — COMPLETE
|
||||
|
||||
**Risk:** Medium. WorldSession lifecycle is load-bearing — every
|
||||
session-state crash would surface here. The change is a class
|
||||
extraction with the same event subscriptions, not a rewrite.
|
||||
The Release suite, connected R6 soak, world-lifecycle screenshots/checkpoints,
|
||||
graceful reconnect, local locomotion/collision/projectile/teleport gate, and
|
||||
two-client portal observer gate form the pre-refactor baseline. Each later
|
||||
slice runs the subset capable of detecting its risk; render/session slices run
|
||||
the complete connected lifecycle gate.
|
||||
|
||||
**Test:** Existing `AcDream.Core.Net.Tests` already cover the wire
|
||||
layer. The controller itself gets a smoke test that verifies it can be
|
||||
constructed without a live socket (offline mode).
|
||||
#### Slice 1 — finish selection/interaction ownership — COMPLETE 2026-07-21
|
||||
|
||||
**Verification:** Visual login + Holtburg traversal + door interaction
|
||||
identical to pre-extraction.
|
||||
Detailed execution plan:
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md`](../plans/2026-07-21-gamewindow-slice-1-selection-interaction.md).
|
||||
|
||||
### Step 3 — `LiveEntityRuntime` — SHIPPED 2026-07-14
|
||||
Split the old Step 4 into three reviewable commits:
|
||||
|
||||
**Shipped scope:** One `LiveEntityRecord` per server-object incarnation now owns
|
||||
ServerGuid↔local-ID translation, accepted state, runtime components, parent
|
||||
relations, logical resource activation, exact teardown, and spatial projection
|
||||
state. `RegisterLiveEntity`, `RebucketLiveEntity`, and `UnregisterLiveEntity`
|
||||
make the lifetime boundary explicit. Landblock unload/reload moves the same
|
||||
`WorldEntity`; it cannot reconstruct from a stale CreateObject or replay setup
|
||||
scripts. Equipped children use an attached projection and never enter the
|
||||
top-level target/radar/status view. Canonical materialized lookup remains
|
||||
available while a projection is pending; the separate visible view is the only
|
||||
surface radar, picking, status, and targeting consume. Pickup/parent leave-world
|
||||
clears cell membership and pauses root movement/animation without destroying the
|
||||
retained owners. Top-level spawn publication is one-shot per incarnation, so
|
||||
leave/re-entry restores presentation without duplicating plugin event replay.
|
||||
1. `WorldSelectionQuery` receives camera/scene/live-object read seams and owns
|
||||
picking, target classification, selection bounds, closest-target lookup,
|
||||
names, and descriptions. It cannot mutate selection or send packets.
|
||||
2. `SelectionInteractionController` owns selection intents, double-click Use,
|
||||
Use/PickUp packet requests, range decisions, speculative facing, and the
|
||||
pending auto-walk action. It composes the existing item/combat controllers
|
||||
rather than duplicating their rules.
|
||||
3. Selection/Use/PickUp/combat-target `InputAction` cases delegate to the new
|
||||
controller; the old methods and fields are deleted from `GameWindow`.
|
||||
|
||||
**Remaining target:** the player-specific controller is still a separate
|
||||
aggregation. The focused effect queue is shipped; AD-32 now tracks only the
|
||||
future non-effect, non-Parent packet queue.
|
||||
Tests cover read-only query classification separately from stateful intents,
|
||||
including direct use, distant auto-walk completion, corpse/container opening,
|
||||
pickup placement, hostile-only targeting, Hidden objects, and same-GUID reuse.
|
||||
The cutover landed in `047a4c83`, `52dbb574`, `e74f2ca9`, `fa8d5232`,
|
||||
`d2bb5af4`, and review-correction commit `5acc3f01`. The final review also
|
||||
ported the shared `ACCWeenieObject::prevRequest` transaction shape across
|
||||
inventory, toolbar, paperdoll, external-container, give, split, merge, and
|
||||
drop routes. Queued actions, pending placements, and responses are bound to
|
||||
exact object incarnations/tokens; optimistic projection, rollback projection,
|
||||
and authoritative response notices are distinct.
|
||||
|
||||
**Behavior change:** Spatial withdrawal and re-entry now preserve logical
|
||||
identity and active resources instead of replaying create-time effects.
|
||||
Measured against `cf50ee3d`, `GameWindow` fell from 15,723 to 14,912 lines and
|
||||
from 205 to 191 methods; field count stayed at 278 because the slice exchanged
|
||||
legacy state for composed owners. Three independent retail, architecture, and
|
||||
adversarial review loops finished clean. Release build passed; 6,558 tests
|
||||
passed and five fixture/conformance tests skipped intentionally.
|
||||
|
||||
**Risk:** Medium-high. Entity lookup is in every hot path. The change
|
||||
is structural (one owner instead of three) but the lookup semantics
|
||||
must be byte-identical.
|
||||
#### Slice 2 — finish live animation presentation — COMPLETE 2026-07-21
|
||||
|
||||
**Test:** `LiveEntityRuntimeTests` cover duplicate CreateObject, generation
|
||||
replacement, appearance mutation, loaded/pending rebucketing, attached
|
||||
projection, pickup leave/re-entry, canonical-versus-visible lookup, resource
|
||||
rollback, GUID reuse, and idempotent session teardown.
|
||||
Move `TickAnimations`, final rigid/visual part composition, effect-pose
|
||||
publication, and motion-done binding into `LiveEntityAnimationPresenter`.
|
||||
`LiveEntityAnimationScheduler` remains the time/movement owner; the presenter
|
||||
only consumes one scheduler result and publishes the final draw/effect pose.
|
||||
Move the surviving motion diagnostics with this owner instead of adding more
|
||||
environment reads to `GameWindow`.
|
||||
|
||||
**Verification:** Walk Holtburg, click NPC, open door, pick up item.
|
||||
All four M1 demo targets must still work.
|
||||
Tests pin legacy and sequencer paths, Hidden/static eligibility, ObjScale versus
|
||||
rigid effect poses, part availability, same-frame hook pose publication, and
|
||||
incarnation replacement. Run the R6 object-frame and projectile/effect suites
|
||||
plus the connected locomotion/projectile gate.
|
||||
|
||||
### Step 4 — `SelectionInteractionController`
|
||||
Result: `LiveEntityAnimationPresenter` owns final visual/rigid part
|
||||
composition, effect-pose publication, MotionDone binding, and typed diagnostics.
|
||||
Schedules carry exact record/entity/component/epoch/projection/presentation
|
||||
identity and own their authored frame prefix until the next scheduler tick.
|
||||
Appearance and static-owner rebinding invalidate stale work. Named-retail
|
||||
`CPartArray::UpdateParts` short-frame retention now preserves both visual and
|
||||
rigid trailing poses. `GameWindow` lost 366 lines, two methods, and one net
|
||||
field. Three independent retail, architecture, and adversarial review loops
|
||||
finished clean; focused App/Core tests and the complete App suite pass.
|
||||
|
||||
**Scope:** Extract `WorldPicker`, the Core `SelectionState` consumers, `SendUse`,
|
||||
`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
|
||||
`SelectionPickUp` switch cases into one controller. Depends on Step 3
|
||||
(uses `LiveEntityRuntime`).
|
||||
#### Slice 3 — complete live-session ownership — COMPLETE 2026-07-21
|
||||
|
||||
**Behavior change:** None.
|
||||
Detailed execution plan:
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-3-live-session.md`](../plans/2026-07-21-gamewindow-slice-3-live-session.md).
|
||||
|
||||
**Risk:** Low-medium. Selection state is local to interactions; the
|
||||
network outbound side is well-defined (`InteractRequests.BuildUse` /
|
||||
`BuildPickUp`).
|
||||
`LiveSessionController` now owns the complete resolve/create/bind/Connect/
|
||||
CharacterList/select/EnterWorld/Tick/stop/reconnect/dispose transaction. Exact
|
||||
generation and operation-depth gates prevent synchronous lifecycle callbacks
|
||||
from resurrecting a superseded session. Staged teardown makes commands inert,
|
||||
detaches events, gracefully disposes the exact `WorldSession`, detaches the
|
||||
host, and runs the full reset manifest to convergence before reuse.
|
||||
|
||||
**Test:** Selection state machine tests in `tests/AcDream.App.Tests/`.
|
||||
`LiveSessionLifecycleHost` is the narrow App adapter; the router and command
|
||||
owners are transactional and session-bound. `GameWindow` lost `_liveSession`,
|
||||
the router/command fields, and the old startup/reset/wiring/disposal methods.
|
||||
Both UI stacks resolve `controller.Commands`; direct feature sends borrow
|
||||
`CurrentSession` at call time. Forty-seven controller cases plus host,
|
||||
transport-allocation, and structural gates cover failure/reentrancy/reuse.
|
||||
Three independent retail, architecture, and adversarial reviews finished
|
||||
clean. The Release suite passes 6,714 tests with five intentional skips. The
|
||||
306-second connected lifecycle gate passed capped login, five travel/revisit
|
||||
checkpoints, exact graceful close, and uncapped fresh-process reconnect; both
|
||||
client processes exited normally.
|
||||
|
||||
**Verification:** Click-to-select, double-click-to-Use, F-key pickup
|
||||
all still work.
|
||||
`GameWindow` fell another 236 lines and three fields to 14,310 lines / 274
|
||||
fields / 190 methods. Slice 4 is next.
|
||||
|
||||
### Step 5 — `RenderFrameOrchestrator`
|
||||
#### Slice 4 — extract live-entity App integration — COMPLETE 2026-07-21
|
||||
|
||||
**Scope:** Extract the per-frame draw sequence (sky → terrain →
|
||||
opaque mesh → translucent mesh → particles → debug → UI) into a
|
||||
dedicated orchestrator that `GameWindow.OnRender` delegates to.
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md`](../plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md).
|
||||
|
||||
**Behavior change:** None. Same draw order, same GL state.
|
||||
This is two owners, not one replacement god object:
|
||||
|
||||
**Risk:** Medium. GL state management is touchy; the orchestrator
|
||||
must hand the GL context to the same renderers in the same order with
|
||||
the same per-pass state setup.
|
||||
- `LiveEntityHydrationController` owns CreateObject/ObjDesc/Parent/Pickup/Delete
|
||||
integration, DAT-backed appearance hydration, resource registration, and
|
||||
exact teardown callbacks over `LiveEntityRuntime`.
|
||||
- `LiveEntityNetworkUpdateController` owns App routing for accepted
|
||||
Position/Vector/State/Movement updates into the existing remote physics,
|
||||
motion, projectile, presentation, and teleport controllers.
|
||||
|
||||
**Test:** Visual verification only. Render orchestration is hard to
|
||||
unit-test without a GL context.
|
||||
Neither may own a GUID dictionary. Both resolve the current incarnation only
|
||||
through `LiveEntityRuntime` and must preserve the inbound FIFO/authority-token
|
||||
rules. Run the complete live-entity stress suite, R6 connected route, inventory
|
||||
equip/parenting, death/corpse, and portal gates.
|
||||
|
||||
**Verification:** Holtburg at radius 4, radius 8, radius 12 looks
|
||||
identical across all four quality presets.
|
||||
Checkpoints A and B are complete. `d68c83d1`, `5882b308`, and `fcb66198`
|
||||
established the shared origin, lifecycle, remote-motion, and exact-record
|
||||
physics-host seams. `69a2ca0c` extracted appearance/collision/default-pose and
|
||||
projection-withdrawal mechanics, including exact recursive `leave_world`
|
||||
subtree handling and retained retry ownership. Three independent reviews were
|
||||
clean and the Release suite passes 6,799 tests with five intentional skips.
|
||||
Checkpoints C and D are complete. `d10c5f2d` moved CreateObject, first
|
||||
materialization, origin bootstrap, and landblock recovery into
|
||||
`LiveEntityHydrationController`; `fe551496` moved ObjDesc, Parent, Pickup,
|
||||
child-ready, and exact leave-world/re-entry routing. Appearance and attached
|
||||
subtree recovery now use independent authority transactions and preserve the
|
||||
same logical/runtime identities. Checkpoint E is complete in `f38822c4`:
|
||||
Delete, visibility prune, retryable exact-incarnation component cleanup, and
|
||||
derived remote stop-observation lifetime now have focused owners. Checkpoint F
|
||||
completed in `aa90c646`: accepted Position/Vector/State/Movement and the
|
||||
equal-generation CreateObject tail now route through focused owners.
|
||||
Independent authority/version captures survive reentrant callbacks and GUID
|
||||
reuse; shared motion-runtime policy is one-way bound and no extracted owner
|
||||
keeps a second identity dictionary. ForcePosition serializes and stamps one
|
||||
exact canonical outbound Position. Three corrected-diff reviews, the complete
|
||||
6,940-test Release suite, and the 310-second seven-destination connected R6
|
||||
route all passed. `GameWindow` is 10,301 lines / 267 fields / 163 methods.
|
||||
Slice 5 checkpoints A–G are complete. The pipeline owns the serialized worker
|
||||
build, concrete render/physics/static publication, and retryable Near/full
|
||||
retirement. The direct cutover deleted the legacy apply/facade/scratch body and
|
||||
added one retained shared-origin lifetime transaction for teleport, logout,
|
||||
and fast relogin. `GameWindow.cs` was 8,793 raw lines at checkpoint G and no
|
||||
longer owns landblock build/apply/retirement behavior. H then corrected native
|
||||
shutdown ordering so live-session reset converges before the streamer and
|
||||
other reset dependencies are disposed. The final measurement is 8,811 raw
|
||||
lines / 247 fields / 153 methods. The complete Release suite (7,054 pass / 5
|
||||
skip), the 311-second capped/reconnect lifecycle gate, and the synchronized
|
||||
394-second nine-stop resource soak all pass (`4a205a3e`).
|
||||
|
||||
### Step 6 — `GameEntity` aggregation (the big one)
|
||||
#### Slice 5 — extract landblock presentation — COMPLETE
|
||||
|
||||
**Scope:** Consolidate `WorldEntity` + `AnimatedEntity` + the per-entity
|
||||
state in `LiveEntityRuntime` into one `GameEntity` class (the target
|
||||
described in `acdream-architecture.md`). Every entity in the world —
|
||||
player, NPC, monster, door, item — becomes a single `GameEntity`.
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md`](../plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md)
|
||||
(complete).
|
||||
|
||||
**Behavior change:** None at the wire / visual level; substantial at
|
||||
the call-site level (everyone moves to the new entity API).
|
||||
Create `LandblockPresentationPipeline` around the existing immutable
|
||||
`LandblockBuild` transaction. It owns DAT build context, scenery/EnvCell
|
||||
construction, mesh/collision/light publication, demotion/full-retirement, and
|
||||
the single-reader DAT lock contract. `StreamingController` continues deciding
|
||||
what is resident; `GpuWorldState` continues owning spatial buckets.
|
||||
|
||||
**Risk:** High. Touches every system that reads entity state.
|
||||
Tests pin loaded/pending/demoted/unloaded symmetry, stale build generations,
|
||||
resource pin/release balance, collision footprints across landblock seams, and
|
||||
first-login bootstrap replacement. The deterministic world-lifecycle gate and
|
||||
nine-destination resource soak pass.
|
||||
|
||||
**Test:** All existing tests + the new `AcDream.App.Tests` suite. Visual
|
||||
verification at every M1 / M2 scenario.
|
||||
#### Slice 6 — extract update-frame orchestration — COMPLETE 2026-07-22
|
||||
|
||||
**Verification:** Full M2 demo loop (equip sword, kill drudge, pick up
|
||||
loot, open inventory) works identically.
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](../plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md)
|
||||
(complete).
|
||||
|
||||
After the stateful bodies above have owners, make the update path a real
|
||||
orchestrator instead of delegates back into the window. It preserves the
|
||||
accepted production order—not an overclaimed retail host order—covering
|
||||
teardown/clock/streaming convergence, input, the retail-shaped object → inbound
|
||||
network → command barrier, conditional spatial reconciles, liveness, local
|
||||
teleport, player-mode entry, and camera presentation. The exact twelve-phase
|
||||
graph and registered TS-53 host-order adaptation live in the detailed ledger.
|
||||
`GameWindow.OnUpdate` becomes a short time/input handoff.
|
||||
|
||||
Frame-order tests and the existing R6 gate must produce the same lifecycle and
|
||||
movement traces before and after extraction.
|
||||
|
||||
The production cutover is complete in `e91f3102`. `GameWindow.OnUpdate` now
|
||||
contains the profiler scope and one `UpdateFrameOrchestrator.Tick` handoff;
|
||||
streaming, input, the object/network barrier, liveness, teleport, player-mode,
|
||||
and camera phases have typed owners with no substantial callback facade into
|
||||
the window. The complete Release suite passes 7,182 tests with five intentional
|
||||
skips. The 314-second lifecycle/reconnect gate and 394-second synchronized
|
||||
nine-stop resource soak both pass with graceful exits, stable live-owner
|
||||
counts, and a 0.8 ms update-frame p95 at the final Caul checkpoint.
|
||||
`GameWindow` is 7,026 raw lines / 241 fields / 108 methods: 8,697 lines (55.3%)
|
||||
smaller than the campaign baseline. TS-50, TS-51, and TS-53 remain accurately
|
||||
registered; this behavior-preserving extraction introduced no new divergence.
|
||||
|
||||
#### Slice 7 — extract `RenderFrameOrchestrator` — COMPLETE 2026-07-22
|
||||
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md`](../plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md)
|
||||
(complete).
|
||||
|
||||
Move the complete draw graph and its reusable frame-local scratch state into a
|
||||
GL-owning App collaborator. Preserve the exact modern pipeline order, clip
|
||||
routing, PView flood, landscape/opaque/shared-alpha flush boundaries,
|
||||
particles, debug draw, paperdoll, retained UI, and frame fences. Do not pass a
|
||||
hundred individual delegates or let the orchestrator reach back into
|
||||
`GameWindow`; inject a small immutable service set plus explicit per-frame
|
||||
input.
|
||||
|
||||
Automated acceptance uses framebuffer artifacts and render/resource
|
||||
checkpoints. Visual acceptance compares outdoor, building, dungeon, portal
|
||||
exit, translucent lifestone/particles, and UI at the same camera positions.
|
||||
|
||||
The production draw cutover is complete. `GameWindow.OnRender` now performs one
|
||||
value-only handoff to `RenderFrameOrchestrator`; focused resource, world,
|
||||
private-presentation, and diagnostic owners contain the former frame body and
|
||||
failure recovery. The Release suite passes 7,341 tests with five intentional
|
||||
skips. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines
|
||||
(70.3%) smaller than the campaign baseline. The 315.6-second lifecycle and
|
||||
395.2-second synchronized nine-stop soak pass with code-zero graceful exits;
|
||||
the six stable framebuffer checkpoints preserve the Slice 6 presentation.
|
||||
Issue #232 records the coarse process-residency gate's run-to-run variance so
|
||||
future diagnostics can distinguish canonical owner growth from OS/driver/GC
|
||||
residency without weakening leak detection.
|
||||
|
||||
#### Slice 8 — composition and shutdown cleanup — ACTIVE
|
||||
|
||||
Detailed execution ledger:
|
||||
[`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`](../plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md).
|
||||
|
||||
Keep GL/window construction in `GameWindow.OnLoad`, but group creation into
|
||||
small composition functions and delete feature state left behind by prior
|
||||
slices. `OnClosing` delegates to the existing retryable shutdown transaction.
|
||||
Silk callbacks become narrow calls into the input, update, render, resize,
|
||||
focus, and shutdown owners.
|
||||
|
||||
Checkpoints E, F, G, and H are complete. `CameraPointerInputController` owns raw pointer,
|
||||
camera cursor/focus/scroll/sensitivity behavior; `FramebufferResizeController`
|
||||
alone publishes physical framebuffer changes. All Silk, dispatcher, retained
|
||||
UI, and optional devtools device edges are reversible, transactional, and
|
||||
terminal after disposal. Logical cutoff precedes live-session retirement;
|
||||
physical detach follows it, so neither copied callbacks nor a failed event
|
||||
remove can interfere with graceful transport teardown.
|
||||
|
||||
`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`
|
||||
now has no settings-store construction, duplicate settings loads, persisted
|
||||
settings mirrors, or display/quality feature bodies. `RuntimeSettingsController`
|
||||
is constructed before `Window.Create`, owns the one immutable startup snapshot
|
||||
and current/toon settings state, applies startup display/pacing/FOV/audio once,
|
||||
then borrows late-bound runtime targets without replay. Saved FOV now applies at
|
||||
startup even when devtools are disabled; this is a deliberate correction of the
|
||||
old accidental SettingsVM gate. The controller also preserves unrelated unsaved
|
||||
Gameplay draft fields when combat preferences change. `GameWindow` is now 3,663
|
||||
raw lines / 162 fields / 37 methods at G. H adds sole lifetime roots for the
|
||||
terrain atlas and dedicated sky shader, one retained Host/runtime lease, an
|
||||
atomic update/render frame-root slot, and a prepare-aware portal fallback and
|
||||
transfer slot. GL construction and state mutations now use checked commit
|
||||
boundaries with exact retry ownership for failed names, bindless residency,
|
||||
and texture-binding restoration. `GameWindow` is 3,689 raw lines / 162 fields /
|
||||
37 methods. Checkpoints I–L remain active, with ordered production composition
|
||||
next. The Checkpoint H production App build is warning-free, its 61 focused
|
||||
ownership tests pass, the complete Release suite passes 7,606 tests / 5 skips,
|
||||
and all three final corrected-diff reviews are clean.
|
||||
|
||||
### 4.4 Exit criteria
|
||||
|
||||
The campaign is complete when:
|
||||
|
||||
- `GameWindow` contains no AC gameplay algorithm, entity scan, packet builder,
|
||||
DAT landblock builder, animation-part composer, or draw-graph body;
|
||||
- `OnUpdate`, `OnRender`, `OnInputAction`, and live-session callbacks are short
|
||||
delegation methods;
|
||||
- every extracted owner has App-layer tests and symmetric teardown;
|
||||
- the Release suite and connected lifecycle/resource gates stay green after
|
||||
every slice;
|
||||
- the final local visual matrix passes unchanged.
|
||||
|
||||
Line count is a progress signal, not the acceptance test. The expected result
|
||||
is below roughly 5,000 lines, but ownership and dependency direction decide
|
||||
completion. Full `GameEntity` type aggregation is evaluated only afterwards as
|
||||
a separate migration; it is not folded into this campaign.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rules of the road during the extraction
|
||||
|
||||
1. **One step at a time.** A PR that ships Step 1 ships only Step 1.
|
||||
Bundling steps makes failures hard to isolate.
|
||||
2. **Behavior preservation is the acceptance criterion.** Every step
|
||||
1. **One slice at a time.** Each commit ships one ownership boundary or
|
||||
mechanical call-site cutover. Bundling slices makes failures hard to
|
||||
isolate.
|
||||
2. **Behavior preservation is the acceptance criterion.** Every slice
|
||||
must build clean, all tests pass, and visual verification at the
|
||||
appropriate M1 / M2 scenarios must succeed. We're moving code, not
|
||||
appropriate accepted milestone scenarios must succeed. We're moving code, not
|
||||
changing it.
|
||||
3. **No new features during an extraction step.** If you spot a real
|
||||
bug while extracting, file it in `docs/ISSUES.md` and address it in
|
||||
|
|
@ -484,9 +763,15 @@ loot, open inventory) works identically.
|
|||
moves to a new owner, the diagnostic flag inside it can move to a
|
||||
diagnostic class as part of the same commit. We do not do a bulk
|
||||
diagnostic-cleanup pass.
|
||||
5. **Update this document when the plan changes.** If Step 3 turns out
|
||||
to need a different shape than described above, update §4 in the
|
||||
5. **Update this document when the plan changes.** If a slice turns out
|
||||
to need a different ownership shape than described above, update §4 in the
|
||||
same session you discover the divergence.
|
||||
6. **No façade-only completion claims.** A delegate from a new class back to a
|
||||
substantial `GameWindow` method is a useful intermediate seam, not a
|
||||
completed extraction.
|
||||
7. **No duplicate ownership.** New collaborators query `LiveEntityRuntime`,
|
||||
`GpuWorldState`, `SelectionState`, or the relevant existing owner. They do
|
||||
not create replacement GUID, visibility, session, or resource maps.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -494,8 +779,9 @@ loot, open inventory) works identically.
|
|||
|
||||
- **Not a full rewrite plan.** The point is the *opposite* — small
|
||||
steps, verified at each boundary.
|
||||
- **Not blocking M2.** Step 1 is small enough to ship without
|
||||
disrupting M2 work. Later steps interleave with M2 / M3 phases as
|
||||
the corresponding code paths come into focus.
|
||||
- **Not a feature phase.** Following the 2026-07-21 user decision, this
|
||||
behavior-preserving campaign is the structural prerequisite before new M4
|
||||
subsystem bodies are added. Severe regressions remain fixable in separate
|
||||
commits; ordinary feature work waits.
|
||||
- **Not a substitute for the milestones / roadmap.** Those drive the
|
||||
feature work. This drives the structural work that runs underneath.
|
||||
|
|
|
|||
|
|
@ -56,38 +56,38 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, radar `0x21000074`, and external container `0x21000008` (shared bevel plus a user-directed compact 700-pixel initial content width instead of its authored 800-pixel root). The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/ExternalContainerController.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle; the external strip's initial width follows the connected visual direction and remains horizontally resizable to its authored extent or the viewport edge. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests. The external strip opens 100 pixels narrower than the raw LayoutDesc before user/persistence resizing. | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `gmExternalContainerUI @ 0x004CBAD0..0x004CBFE0`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
||||
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`) | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
||||
| IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Adaptation (AD) — 37 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
## 2. Adaptation (AD) — 38 rows (AD-31 retired 2026-07-15 — the DAT-authored portal-space viewport replaces the black transit cover)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
|
||||
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
|
||||
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (login auto-entry + TAS transit tick); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal an empty collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
|
||||
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal an empty collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
|
||||
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
|
||||
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
|
||||
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |
|
||||
| AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 |
|
||||
| AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 |
|
||||
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 |
|
||||
| AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow null for the established creature/large-object fallback while preserving explicit zero/one rejection | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsUseable`) | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally leaves without useability can become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923; `ItemUses::IsUseable @ 0x004FCCC0` |
|
||||
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
||||
| AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB |
|
||||
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
|
||||
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
|
||||
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameBuildingSource.Gather`) | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
|
||||
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
|
||||
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
|
||||
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
|
||||
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
|
||||
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
||||
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/WorldSceneRenderer.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
||||
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
||||
| AD-25 | **REMOTE-DR sweep only** (the player half retired 2026-07-07 by the #182 verbatim rebuild): the remote dead-reckoning post-resolve still reflects velocity with the airborne-before-AND-after suppression; retail bounces unless grounded→grounded-and-not-sledding. The PLAYER path now runs the ported `handle_all_collisions` (`PhysicsObjUpdate`) with retail's `should_reflect` rule — the micro-bounce spiral it guarded is gone (contact is committed BEFORE the reflect and the small-velocity-zero is ungated) | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep post-resolve, #173 block) | The remote DR sweep hasn't been rebuilt yet (it has no fsf/SetPositionInternal chain); the old airborne-only suppression keeps remote landings from micro-bouncing on the remote landing-snap gate | Remote landing-reflection behavior (slope-landing momentum) won't reproduce; retire when the remote-DR sweep gets the same UpdateObjectInternal rebuild as the player | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 |
|
||||
| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it). The companion `MoveToCancelled` seam only withdraws the waiting client presentation/action. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`OnNaturalMoveToComplete`/`OnMoveToCancelled`); `src/AcDream.App/Rendering/GameWindow.cs` (player MoveTo seam binding); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete`/`MoveToCancelled`) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract; cancellation/error only clears retained intent. | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) |
|
||||
| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 |
|
||||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
||||
|
|
@ -102,10 +102,12 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.App/Input/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) |
|
||||
| AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 |
|
||||
| AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 |
|
||||
| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` |
|
||||
| AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Documented approximation (AP) — 85 active rows
|
||||
## 3. Documented approximation (AP) — 88 active rows
|
||||
|
||||
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
|
||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||
|
|
@ -130,8 +132,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 |
|
||||
| AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 |
|
||||
| AP-21 | Entity translucency retains the invented α<0.05 fragment discard. World GfxObj/Setup instances now apply their DAT AlphaBlend/Additive/InvAlpha factors through the retail shared alpha queue, but sealed off-screen WbDrawDispatcher consumers (paperdoll/UI Studio) retain the old immediate normal-alpha pass for all three kinds | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`DrawDeferredAlphaBatch` versus immediate Phase 8) | World presentation needed exact per-surface blend for spell/particle density and translucent-object intersections. Off-screen object previews are isolated render targets and have not shown an authored additive entity surface that justifies splitting their compact immediate pass | A faint world fringe below 5% alpha is discarded; a hypothetical additive/inverse-alpha paperdoll or UI Studio entity composites darker than retail inside that private viewport | `D3DPolyRender::SetSurface`; `D3DPolyRender::RenderMeshSubset`; SurfaceType.Additive → D3DBLEND_ONE |
|
||||
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
||||
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 |
|
||||
| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
|
||||
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.App/Input/PlayerMovementController.cs`; `src/AcDream.App/Combat/CombatAttackController.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
|
||||
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
|
||||
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
|
||||
|
|
@ -139,11 +141,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
|
||||
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
|
||||
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
|
||||
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
||||
| AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` (`ShellDrawLiftZ`); `src/AcDream.App/Rendering/RetailPViewPassExecutor.cs` (`DrawPortalDepthWrite`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) |
|
||||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
||||
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewPassExecutor.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
||||
| AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
|
|
@ -169,7 +171,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` |
|
||||
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
|
||||
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
|
||||
| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `GameWindow.OnLiveEntityPruned` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` |
|
||||
| AP-69 | acdream now preserves one accepted live record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry routes through the exact generation-safe F747 teardown. DIVERGENCE: the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/Streaming/GpuWorldState.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while retaining loaded long-view objects and canonical identity | A nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` |
|
||||
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
||||
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
||||
| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values |
|
||||
|
|
@ -179,18 +181,18 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification |
|
||||
| AP-77 | **NARROWED 2026-07-19 — animation-less/headless movement fallback only.** When `MotionInterpreter.DefaultSink` or the local PartArray callback is absent, acdream writes grounded command-derived body velocity and applies the DAT-pinned Humanoid `TurnRight` rate (1.5 radians/second) directly to the body Frame. Production animated players/remotes bind `MotionTableDispatchSink` plus CSequence and instead consume the complete DAT-authored root Frame; that path preserves airborne orientation while suppressing only origin exactly like retail | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`); `src/AcDream.App/Input/PlayerMovementController.cs` (no-PartArray object-quantum fallback) | Keeps isolated/headless physics tests and a deliberately animation-less entity controllable without fabricating a PartArray | A future production entity missing its animation binding uses Humanoid-only yaw/velocity, can foot-slide, and can rotate through an airborne quantum differently from a real creature's DAT Frame | `CMotionInterp::apply_interpreted_movement` 0x00528600; `CPhysicsObj::UpdatePositionInternal` 0x00512C30; retire when animation-less production objects have an explicit motion owner |
|
||||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
|
||||
| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
|
||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
|
||||
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection + authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||
| AP-86 | **Remote SHADOW-follows-resolved via a pose/cell-gated per-tick re-flood** (remote-creature de-overlap #184): every remote's collision shadow is rewritten at the resolved body position by the DR tick or authoritative UP tail, so collision remains where the creature renders and de-overlap persists. The effect matches retail, but acdream runs the full multipart cell flood whenever the body moved more than 1 cm, changed complete orientation, or crossed a cell instead of translating the existing shadow in place and relinking only when its crossed-cell set changes. Cross-cell motion now commits body/root/full-cell before the canonical rebucket callback; local and authoritative remote publishers prove exact-record spatial residency after that callback; pending projection suspends the retained shadow and cannot re-add it, including initial-pending and callback GUID-reuse cases. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`; `src/AcDream.App/Physics/LiveEntityShadowPublisher.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (local projection); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (authoritative UP tails); `src/AcDream.App/World/LiveEntityPresentationController.cs` (ordinary projection residency); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The pose/cell gate is exact at de-overlap equilibrium, preserves offset/multipart shapes during in-place turns, and the resulting registered cell set matches retail; loaded/pending residency is symmetric and incarnation-scoped | A dense moving or turning crowd can still perform a full registration flood per creature per tick and create CPU/Gen0 pressure; a still crowd is gated out. Retire with an in-place move plus cell-relink-on-change implementation | `CPhysicsObj::SetPositionInternal(CTransition const*)` 0x00515330 → `change_cell`, then `remove_shadows_from_cells`/`add_shadows_to_cells` after the resolved frame/contact commit |
|
||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
||||
| AP-90 | **Radar fellowship/allegiance relationship state is modeled but not yet delivered at runtime.** `RetailRadar.GetBlipShape` and `RadarBlipColors.For` implement retail's leader/member/allegiance precedence, and `RadarSnapshotProvider` exposes a `relationshipFor(guid)` seam, but acdream does not yet maintain live fellowship membership and its `AllegianceTree` is not wired into GameWindow. PK/PKLite relationship shapes do work from PWD flags. | `src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs`; `src/AcDream.Core/Ui/RetailRadar.cs`; `src/AcDream.Core/Ui/RadarBlipColors.cs` | Preserve the exact model/seam now and avoid inventing membership from names or chat; connect it when the social game-event state is ported | Fellowship members render their ordinary player color/shape instead of bright-green leader/member triangles; allegiance members render an ordinary plus instead of a hollow box | `gmRadarUI::GetBlipColor` 0x004D76F0; `gmRadarUI::GetBlipShape` 0x004D7B60 |
|
||||
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||
| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; paperdoll mount wiring | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
|
||||
| AP-92 | Paperdoll renders its creature through a private FBO/RTT and blits it into `UiViewport`; retail renders `CreatureMode` directly in the UI viewport/in-cell presentation path | `src/AcDream.App/Rendering/PaperdollFramePresenter.cs`; `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`; `src/AcDream.App/UI/UiViewport.cs` | GL RTT is the modern backend equivalent and the accepted pixels/camera/pose match retail; state is sealed with `GLStateScope` | FBO origin, alpha, lighting, or state isolation can diverge from direct viewport rendering | `gmPaperDollUI::PostInit @ 0x004A5360`; `UIElement_Viewport::SetCamera`; retail `CreatureMode::Render` |
|
||||
| AP-93 | Paperdoll does not port `UpdateForRace`; all characters use the cdb-confirmed default held pose `0x030003C0` and default presentation | `src/AcDream.App/Rendering/DollEntityBuilder.cs`; `RetailPaperdollPoseApplicator` in `src/AcDream.App/Rendering/PaperdollFramePresenter.cs` | Exact for the tested Horan character; unresolved for other heritage/gender/body combinations | Non-default races can use the wrong pose, heading, camera framing, or presentation asset | `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` |
|
||||
| AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` |
|
||||
| ~~AP-95~~ | **RETIRED 2026-07-11** — `UiButton` owns retail hover/pressed/released-outside, disabled, selected/toggle, missing-state fallback, hot-click, and distinct press/release controller callbacks. | `src/AcDream.App/UI/UiButton.cs`; `UiButtonStateMachine.cs` | — | — | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
|
||||
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
|
||||
|
|
@ -213,8 +215,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
|
||||
| AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` |
|
||||
| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
|
||||
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
|
||||
| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 34 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
|
||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -246,20 +251,24 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) |
|
||||
| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 |
|
||||
| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly |
|
||||
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
||||
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
|
||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||
| ~~TS-41~~ | **RETIRED 2026-07-07 (remote-creature de-overlap #184)** — the SERVERVEL synth-velocity body-drive (`Body.Velocity = ServerVelocity` / `get_state_velocity()` leg) is DELETED. Grounded NPC remotes now translate by the retail interp CATCH-UP (`RemoteMotionCombiner.ComputeOffset` → `InterpolationManager::adjust_offset` toward the MoveOrTeleport-queued server waypoint) and `MovementManager::UseTime` (`TickRemoteMoveTo`) runs UNCONDITIONALLY per tick — the retail `UpdateObjectInternal` shape (no wire-velocity leg-driver). The de-overlap sweep resolves the catch-up movement; the resolved position is written back into the SHADOW (AP-86) so it persists. Residual: the non-retail anim-cycle stale-stop heuristic (`ApplyServerControlledVelocityCycle(Zero)` on a >0.6 s velocity-staleness timer) is kept as ANIM-only and stays covered by **AP-80**; it no longer drives the body. | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (grounded NPC branch) | — | — | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` @0x00515998, unconditional); `MoveOrTeleport` 0x00516330; `InterpolationManager::adjust_offset` 0x00555d30 |
|
||||
| TS-44 | NPC UpdatePosition **enqueue is suppressed while StickyManager is armed** (`PositionManager.GetStickyObjectId() != 0`). Position and complete orientation otherwise share the ported `InterpolateTo → Position::subtract2 → PositionManager::adjust_offset` Frame, so the former orientation hard-snap residual is retired. Retail would still enqueue the server Position and let Sticky overwrite that Frame each tick; acdream retains the gate so no queued waypoint survives the stick | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (NPC `snapSuppressedByStick` gate) | Avoids replaying an old ACE waypoint immediately after a stick lease ends; all live during-stick pose ownership is now otherwise retail-shaped | After unstick the body waits for the next UP instead of consuming the latest waypoint already in the queue; at low packet cadence this can pause correction for one update interval | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330; retire by allowing enqueue while Sticky overwrites the shared complete Frame |
|
||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupCylinder`); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (remote sweep fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-91` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
||||
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Rendering/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
||||
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; frame drain in `src/AcDream.App/Rendering/GameWindow.cs` | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
||||
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each object's ParticleManager and ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` also advances that static owner's managers using its whole admitted elapsed interval. | `src/AcDream.App/Rendering/GameWindow.cs` (`AdvanceLiveObjectRuntime` final `_particleSystem.Tick(dt)` / `_scriptRunner.Tick(...)`) | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and manager order faithful without pretending the shared tails have per-owner timing. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static default scripts/particles use render elapsed rather than `animate_static_object` elapsed/discard timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum |
|
||||
| TS-49 | Hidden-object availability is bridged through `TargetManager.NotifyVoyeurOfEventAndClear(ExitWorld)` because acdream has not ported retail's DetectionManager. Retail `CObjCell::hide_object` sends `LeftDetection` to detection voyeurs; acdream instead withholds Hidden hosts from ordinary `GetObjectA` relationship creation and uses the existing non-Ok target update to tear down MoveTo/Sticky consumers and clear watched-role subscriptions while preserving the hidden object's own watcher role. | `src/AcDream.App/Physics/EntityPhysicsHost.cs` (`NotifyHidden`); `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`ResolvePhysicsHost`); `src/AcDream.Core/Physics/Motion/TargetManager.cs` (`NotifyVoyeurOfEventAndClear`) | The current movement consumers already share TargetManager's status fan-out; the bridge prevents pursuit of an unavailable object without inventing a second partial detection database. | Plugins or future systems listening specifically for retail detection enter/leave events receive no `LeftDetection`; only movement/sticky target consumers observe the equivalent availability loss. | `CObjCell::hide_object @ 0x0052BE30`; retire by porting DetectionManager/CObjCell detection-voyeur delivery and routing Hidden through `LeftDetection` |
|
||||
| TS-50 | `AnimationDone` executes semantically at each owner's retail `CPhysicsObj::process_hooks` boundary, but all other animation hooks are retained in `AnimationHookFrameQueue` until final root/part/equipped-child pose publication. Retail executes the complete hook stream before transition and the Target/Movement/PartArray/Position manager tail because its current CPartArray pose already exists in-place. Static owners correctly reach `process_hooks` only after their root, parts, and children are current. | `src/AcDream.App/Rendering/Vfx/AnimationHookFrameQueue.cs`; `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs`; shared frame drain in `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveEffectFrameController`) | The modern renderer publishes immutable effect-pose snapshots after all root/child composition; deferred visual sinks avoid attaching particles/lights/audio to the previous pose. Semantic `AnimationDone` is split out and exact, so motion completion and manager behavior are not delayed. Pose-owner lifetime tokens prevent deferred hooks from crossing delete/local-ID reuse. | A non-AnimationDone hook with same-quantum semantic consequences (notably `CallPES`, default-script chaining, audio/particle creation relative to a transition) runs later than retail and can observe post-tail state or start one render frame late. | `CPhysicsObj::process_hooks @ 0x00511550`; `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by publishing the current per-object/child pose before hook routing or splitting semantic and presentation sinks without changing authored hook order |
|
||||
| TS-51 | Particle and PhysicsScript tails advance once per render frame after the complete ordinary/static object worksets. Retail advances each ordinary object's ParticleManager then ScriptManager inside every admitted `UpdateObjectInternal` quantum; `animate_static_object` instead advances that static owner's ScriptManager then ParticleManager and only then `process_hooks`, using its whole admitted elapsed interval. acdream's shared tail is Particle → Script after static hook capture. | `src/AcDream.App/Update/LiveObjectFrameController.cs` (`LiveObjectFrameController` + `LiveEffectFrameController` shared `_particles.Tick` / `_scripts.Tick` tail); `src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs` | The current managers are shared presentation/runtime owners rather than per-object manager instances. R6 makes root motion, animation, object clocks, workset membership, and ordinary manager order faithful without pretending the shared tails have per-owner timing or static-tail order. Splitting ownership safely requires a later effect-lifetime slice. | A render fragment below retail's minimum object quantum can advance an effect while its owner waits; a catch-up frame advances an owner's root through several quanta but its effect tail only once; static hooks can route before their script/particle managers and static default scripts/particles use render elapsed in Particle → Script order rather than `animate_static_object` elapsed/discard and Script → Particle → hooks timing. | `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`; `CPhysicsObj::animate_static_object @ 0x00513DF0`; retire by giving live/static owners incarnation-bound particle/script managers and ticking each manager in the owning object quantum/order |
|
||||
| TS-52 | The terrain shader applies retail-authored base/overlay/road `TerrainTex.TexTiling` but omits the separate Environment Detail Textures pass and its viewer-distance fade (**#226**). | `src/AcDream.App/Rendering/TerrainAtlas.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs`; `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | `bb5acab9` fixed the user-visible stretched/blurry regression by porting the distinct base-tiling contract. An earlier experimental detail array darkened the whole ground because its source/neutral blend contract was wrong, so it was correctly reverted rather than guessed into production. | With retail's Environment Detail Textures preference enabled, close terrain lacks the extra high-frequency modulation/fade even though authored base texture scale is correct. | `LScape::GenerateDetailSurfaces` / `SetDetailTexturing @ 0x00506B40`; `ACRender::landPolyDraw @ 0x006B6450..0x006B6525`; issue #226 |
|
||||
| TS-53 | acdream advances retained UI time on the draw seam and local teleport/UI-camera presentation after its SmartBox-shaped object → inbound network → CommandInterpreter barrier. Retail `Client::UseTime` calls `UIElementManager::UseTime` first, whose global time message reaches `gmSmartBoxUI::UseTime`, and publishes player-camera work from the physics/player callback rather than one post-network camera tail. Slices 6–7 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
|
||||
| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
|
||||
| TS-55 | AdminEnvirons fog values remain a color-only `WeatherSystem.Override` approximation. Retail values 1..5 install authored ambient color/level plus fog color/max; value 6 also forces transition/min/max and blanks radar; Clear restores all override fields and radar; `0x270F` installs a separate authored override. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`); `src/AcDream.Core/World/WeatherState.cs` (`EnvironOverrideColor`) | Preserves the already accepted enum bridge while Slice 8 moves ownership; porting the complete environment/radar presentation is a separate behavior change requiring focused visual gates. | Forced-fog hue, density, scene ambient, and radar blanking differ from retail; `0x270F` is ignored. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055DE2B..0x0055E344`) |
|
||||
| TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Unclear (UN) — 5 rows
|
||||
## 5. Unclear (UN) — 4 rows
|
||||
|
||||
These rows have a missing, contradictory, or never-argued justification.
|
||||
They are the highest-priority audits: each needs either a recorded
|
||||
|
|
@ -268,7 +277,6 @@ equivalence argument (promote to AD/AP) or a fix.
|
|||
| # | Divergence | Where (file:line) | Recorded justification (deficient) | Risk if assumption breaks | Retail oracle |
|
||||
|---|---|---|---|---|---|
|
||||
| UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 |
|
||||
| UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs |
|
||||
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
|
||||
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
|
||||
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
|
||||
|
|
@ -289,7 +297,7 @@ WITH that phase, not before.
|
|||
5. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
|
||||
6. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
|
||||
7. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
|
||||
8. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler.
|
||||
8. **TS-55 — AdminEnvirons fog/radar presentation** — exact retail mechanism is known; port the authored ambient/fog fields, radar blanking, Clear, and `0x270F` together.
|
||||
9. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
|
||||
|
||||
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
|
||||
|
|
|
|||
|
|
@ -1,11 +1,116 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-07-20. **M3 active; automated implementation and connected single-client magic/portal gates complete.** M2's connected demo loop is complete. M3 now has the retail cast-intent/component/target lifecycle, exact live enchantment wire state, scoped Magic input, retained spell bar, spellbook, component book, positive/negative effects panels with authored rows and selected-spell details, the complete seven-control gameplay indicator strip and its Character Information/Link/Vitae detail pages, and shared `gmPanelUI` geometry/switching over the Step 9 projectile/PhysicsScript/particle/portal foundation. The next gate is a local visual rebaseline of the new R6 object-frame path; the final M3 requirement is the two-client portal-out/materialization observer gate. Issue #225's lifestone/particle alpha comparison is a separate rendering visual gate.
|
||||
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 1–7 and Slice 8 checkpoints A–H 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.
|
||||
|
||||
---
|
||||
|
||||
## Current program: M3 — Cast a spell
|
||||
## Current program: `GameWindow` structural stabilization (M4 prerequisite)
|
||||
|
||||
**Outcome:** `GameWindow` becomes a thin Silk.NET composition/lifecycle shell
|
||||
without changing accepted gameplay or rendering behavior. Focused App owners
|
||||
take responsibility for selection, animation presentation, live-session and
|
||||
live-entity integration, landblock presentation, and update/render frame
|
||||
orchestration.
|
||||
|
||||
**Current sequence:** The implementation audit, exact ownership ledger, slice
|
||||
boundaries, tests, connected gates, and exit criteria live in
|
||||
[`docs/architecture/code-structure.md`](../architecture/code-structure.md) §4.
|
||||
Slice 1 selection/interaction landed 2026-07-21: the read-only world query and
|
||||
stateful intent owner are extracted, exact-incarnation lifetime is enforced,
|
||||
and retail's global inventory-request transaction is shared across every UI
|
||||
mutation surface. `GameWindow` is 811 lines and 14 methods smaller.
|
||||
|
||||
Slice 2 live animation presentation landed 2026-07-21: final visual/rigid
|
||||
PartArray composition, effect-pose publication, MotionDone binding, and
|
||||
diagnostics now live in `LiveEntityAnimationPresenter`. Exact incarnation,
|
||||
projection, object-clock, and appearance-revision tokens reject stale work;
|
||||
ordinary/static frame handoffs own their buffers; retail short AnimFrames
|
||||
retain trailing visible and rigid poses. `GameWindow` is now 14,546 lines.
|
||||
|
||||
Slice 3 complete live-session ownership landed 2026-07-21: one
|
||||
`LiveSessionController` now owns resolve through graceful teardown/reconnect,
|
||||
with exact-generation reentrancy gates, transactional typed routing, dynamic
|
||||
UI command publication, and convergent session reset. The old GameWindow
|
||||
session/router/command fields and lifecycle bodies are gone. `GameWindow` is
|
||||
now 14,310 lines, 274 fields, and 190 methods; the Release suite passes 6,714
|
||||
tests with five intentional skips. Its connected gate passed capped login,
|
||||
five travel/revisit checkpoints, graceful close, and uncapped fresh-process
|
||||
reconnect in 306 seconds.
|
||||
|
||||
Slice 4 live-entity App integration landed 2026-07-21: focused hydration,
|
||||
retryable teardown, inbound authority, network-update, equal-generation
|
||||
CreateObject, and reusable motion-runtime owners now sit over the canonical
|
||||
`LiveEntityRuntime`. No extracted owner adds a second GUID dictionary.
|
||||
`GameWindow` is now 10,301 lines, 267 fields, and 163 methods; the Release suite
|
||||
passes 6,940 tests with five intentional skips. The 310-second connected R6
|
||||
gate passed seven destinations/revisits, movement/jump/combat exercises, and
|
||||
graceful close.
|
||||
|
||||
Slice 5 is complete: checkpoints A–G extracted the DAT-backed worker
|
||||
build plus render, physics, DAT-static, and retryable Near/full presentation
|
||||
ownership into `LandblockPresentationPipeline`. Production has no callback
|
||||
facade back into the deleted GameWindow apply bodies. A retained shared-origin
|
||||
transaction retires the complete old window before teleport recenter or
|
||||
session replacement, including failure/retry and fast relogin. Shutdown keeps
|
||||
the reset graph alive until disposal converges. `GameWindow` is 8,811 raw
|
||||
lines / 247 fields / 153 methods (44% below the campaign baseline); 7,054
|
||||
Release tests, the capped/reconnect lifecycle gate, and the synchronized
|
||||
nine-stop resource soak pass.
|
||||
|
||||
Slice 6 update-frame orchestration is complete. Streaming, input, the retail-
|
||||
shaped object/network barrier, liveness, local teleport, player mode, and
|
||||
camera presentation now have typed phase owners. `GameWindow.OnUpdate` is one
|
||||
orchestrator handoff, with no substantial transitive callback facade back into
|
||||
the window. `GameWindow` is 7,026 raw lines / 241 fields / 108 methods, down
|
||||
8,697 lines (55.3%) from the campaign baseline. The complete Release suite
|
||||
passes 7,182 tests / 5 skips; the 314-second lifecycle/reconnect gate and
|
||||
394-second synchronized nine-stop resource soak pass with graceful exits and
|
||||
stable owner counts. The completed ledger is
|
||||
[`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md).
|
||||
Slice 7 render-frame orchestration is complete. `GameWindow.OnRender` now
|
||||
supplies one immutable frame input to focused resource, world-scene,
|
||||
private-presentation, and diagnostic owners. Exact world/PView/alpha/portal/
|
||||
paperdoll/retained-UI/ImGui/screenshot/GPU-close order and failure recovery are
|
||||
pinned by tests. `GameWindow` is 4,666 raw lines / 196 fields / 70 methods,
|
||||
11,057 lines (70.3%) below the campaign baseline. The Release suite passes
|
||||
7,341 tests / 5 skips; the 315.6-second lifecycle/reconnect gate and 395.2-
|
||||
second synchronized soak pass with graceful exits, and the six stable PNG
|
||||
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
|
||||
process-residency gate's diagnostic blind spot; no tolerance was loosened.
|
||||
|
||||
Final Slice 8 composition/shutdown cleanup is active. Checkpoints A–H now own
|
||||
the frozen shell boundary, all nine native callback edges, live-session
|
||||
reset/selection/entry/route composition, and world clock/sky/day/weather/
|
||||
AdminEnvirons state. Checkpoint E adds reversible, transactional Silk,
|
||||
dispatcher, retained-UI, and devtools input edges; a focused raw pointer owner;
|
||||
and one physical framebuffer-size publisher. Logical callback cutoff and
|
||||
live-session retirement precede physical removal, so failed device detach
|
||||
cannot block graceful transport close. `GameWindow` is 4,266 lines / 194
|
||||
fields / 65 methods. Checkpoint F adds the sole gameplay action-priority
|
||||
subscriber, typed combat/diagnostic/general commands, and a reversible
|
||||
retained-root item-drop binding. `GameWindow` is now 4,057 lines / 198 fields /
|
||||
54 methods; the Checkpoint F Release suite passes 7,524 tests / 5 skips.
|
||||
Checkpoint G adds the pre-window `RuntimeSettingsController`, one immutable
|
||||
startup snapshot, ordered startup pacing/display/FOV/audio application, late
|
||||
runtime-target binding without replay, and one current/toon settings owner.
|
||||
`GameWindow` is 3,663 lines / 162 fields / 37 methods at G. Checkpoint H adds
|
||||
sole terrain-atlas and sky-shader roots, one retained Host/runtime lease, an
|
||||
atomic update/render frame-root pair, prepared portal fallback/transfer, and
|
||||
checked retryable GL leaf construction and state mutation. `GameWindow` is now
|
||||
3,689 lines / 162 fields / 37 methods. Ordered composition, shutdown, canonical
|
||||
soak snapshots, and closeout checkpoints I–L remain. Checkpoint H passes 61
|
||||
focused ownership tests and 3,236 App tests / 3 intentional skips; the complete
|
||||
Release suite passes 7,606 tests / 5 skips and all three final corrected-diff
|
||||
reviews are clean.
|
||||
|
||||
This is a behavior-preserving structural program. Severe regressions still get
|
||||
root-cause fixes in separate commits; ordinary feature work resumes with M4
|
||||
after the campaign exit criteria pass.
|
||||
|
||||
---
|
||||
|
||||
## Completed program: M3 — Cast a spell (landed 2026-07-21)
|
||||
|
||||
**Outcome:** Cast a projectile spell at a hostile target, see the complete
|
||||
turn/windup/release/projectile/impact lifecycle, self-cast a buff and see its
|
||||
|
|
@ -34,14 +139,19 @@ active enchantment, then recall through the DAT-driven portal presentation.
|
|||
effects, spellbook/component-book filtering and deletion, favorite spell
|
||||
bar interaction, recall, portal-space presentation, and retained indicator
|
||||
pages.
|
||||
4. **Pending local visual gate:** locomotion, collision, projectile, and
|
||||
teleport comparison after the R6 complete-root-Frame/object-workset cutover.
|
||||
5. **Pending two-client visual gate:** portal-out/materialization observer
|
||||
presentation before M3 closes, including #218's finished Hidden silhouette,
|
||||
fully rendered destination reveal, and no indoor stop-time position snap.
|
||||
4. **Done (automated connected gate):** one canonical world-reveal lifecycle
|
||||
now covers fresh login, outdoor/world-edge/dungeon transitions,
|
||||
same-location revisit, graceful logout, and uncapped fresh-process
|
||||
reconnect. The gate records structured readiness/resource checkpoints and
|
||||
full-frame PNGs, and rejects early world visibility or stranded owners.
|
||||
5. **Done (connected user gate):** locomotion, collision, projectile, teleport,
|
||||
and radar comparison after the R6 complete-root-Frame/object-workset cutover.
|
||||
6. **Done (two-client user gate):** portal-out/materialization observer
|
||||
presentation, including #218's finished Hidden silhouette, fully rendered
|
||||
destination reveal, and no indoor stop-time position snap.
|
||||
|
||||
Opportunistic UI/physics fixes are no longer the work-order driver. They enter
|
||||
the active slice only when they block this demo or represent a severe regression.
|
||||
M3 is closed. Its detailed ledger remains here as the landing record; the
|
||||
structural program above is the only current work-order driver before M4.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -567,6 +677,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
|
||||
- **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125–153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain.
|
||||
- **Render-thread CPU and high-refresh pacing correction 2026-07-19; automated/review/connected gates passed, final translucency visual gate pending.** Software pacing now uses one reusable Windows high-resolution waitable timer instead of scheduler-quantized `Thread.Sleep`; the permanent profiler attributes that wait as `pace`. Wb keeps exact stable far-to-near alpha order while using a radix key, avoids immediate SSBO packing for deferred transparent objects, packs each eight-light set, caches immutable selection descriptors, and retires historical material groups only after one complete unused frame across every landscape/dynamics/paperdoll draw scope. A fixed dense-Caul sample improved from roughly 9–12 ms CPU to 5.3–6.2 ms without reducing view distance, particles, textures, streaming radius, or other quality. The corrected post-review uncapped route waited for every materialization and held a full visibility sweep through one dedicated profiler window at all seven destinations. Returned dense Caul (about 21,024 visible entities) measured 3.0/4.9 ms CPU p50/p95 and 2.0/3.4 ms GPU while turning, then 3.8/5.0 ms CPU and 2.6/3.2 ms GPU stationary (241 FPS title sample). First/return Caul memory was 939.8/1,438.3 and 1,034.8/1,630.5 MiB working/private; the highest stationary sample was 1,065.0/1,660.6 MiB in Holtburg, and the client exited gracefully. With pacing disabled (`pace=0`) and title samples reaching 844 FPS, RDP did not refresh-cap engine throughput. UI automation now uses a command-local compensated clock, preserving sub-millisecond frame deltas and exact long-duration sleep/timeout boundaries.
|
||||
- **World-reveal lifecycle gate and graceful reconnect shipped 2026-07-20.** `WorldRevealCoordinator` is now the single App owner for login/portal begin, canonical render+composite+collision readiness, materialization, normal-world visibility, completion, and cancellation. Diagnostic telemetry observes that owner by generation; it does not duplicate the readiness formula. Retained automation can wait on semantic lifecycle edges, write JSON checkpoints, and capture the complete framebuffer after world and UI draw. The connected Release gate covers login → Aerlinthe → Rynthid → Facility Hub → Holtburg → Aerlinthe, closes through retail's `0xF653` character-logoff plus transport `Disconnect`, proves ACE accepted the exact client endpoint, then performs an uncapped fresh-process reconnect. The final run passed all checkpoints and six PNGs with zero reveal invariant failures or pending teardown/upload/warmup work; the reconnect sampled 172.7 FPS / 5.79 ms on this host. Release build and 6,480 tests pass / 5 skip. See `docs/plans/2026-07-20-automated-world-lifecycle-gate.md`.
|
||||
|
||||
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
|
||||
|
||||
|
|
@ -920,7 +1031,8 @@ diagnostic scaffolding, not yet the final collision system.
|
|||
PositionManager facade remains the final R6 ownership cleanup.
|
||||
Final 2026-07-20 gate: three independent retail/architecture/adversarial
|
||||
re-reviews are clean; Release builds successfully with 17 known test-project
|
||||
warnings tracked by #228; the repository suite is 6,457 passed / 5 skipped.
|
||||
warnings tracked by #228; the repository suite is 6,480 passed / 5 skipped
|
||||
after the world-reveal gate additions.
|
||||
The unattended connected Release rebaseline also
|
||||
passes: seven portal materializations across Caul, Sawato, Rynthid,
|
||||
Aerlinthe, Holtburg, and same-location revisits; production-dispatcher turn,
|
||||
|
|
@ -930,8 +1042,7 @@ diagnostic scaffolding, not yet the final collision system.
|
|||
and 0.2 -> 0.3 ms at Sawato. ACE generator population changes are reported
|
||||
rather than treated as client leaks. Absolute FPS/GPU timing remains
|
||||
session/display-path evidence, not an RDP-stable gate. The connected visual
|
||||
locomotion/collision/projectile/teleport comparison remains for the next
|
||||
local viewing session.
|
||||
locomotion/collision/projectile/teleport/radar comparison passed 2026-07-21.
|
||||
- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).**
|
||||
ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL
|
||||
remote entities (players, NPCs, monsters — one funnel, user-approved):
|
||||
|
|
@ -1446,7 +1557,7 @@ OpenGL ceiling; revisit macOS only if a supported graphics backend is chosen.
|
|||
|
||||
## Cross-cutting work tracked in parallel
|
||||
|
||||
- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,457 passed / 5 intentional skips (2026-07-20).
|
||||
- **Test coverage.** Each phase lands with layer-matched unit and integration tests in `tests/`. Current Release baseline: 6,575 passed / 5 intentional skips (2026-07-21).
|
||||
- **Memory files.** Live subsystem state and digests are indexed by `claude-memory/MEMORY.md`; stable engineering references remain under `memory/`. Canonical program status stays in milestones/roadmap/issues.
|
||||
- **`CLAUDE.md` discipline.** Check all four references (ACE, ACViewer, WorldBuilder, Chorizite) before committing to an approach. WorldBuilder is the closest stack match and should be checked first.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,24 +2,69 @@
|
|||
|
||||
**Status:** Living document. Created 2026-05-12.
|
||||
**Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index).
|
||||
**Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge"
|
||||
LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
|
||||
inventory, and item-giving demo is user-gated. The shared projectile/effect
|
||||
foundation is hardened through Step 9. Spellbook/component-book filtering,
|
||||
favorite spell bar, connected casting/enchantment effects, and portal-space
|
||||
presentation have passed their single-client visual gates. The corrective bounded
|
||||
render/resource-lifetime integration passed its seven-destination connected
|
||||
stress gate without a crash or cumulative collapse. The remaining M3 visual
|
||||
requirement is the final two-client portal-out/materialize observer gate. The
|
||||
new R6 object-update cutover has passed its unattended connected input/portal/
|
||||
performance rebaseline; its locomotion/collision/projectile/teleport visual
|
||||
gate remains for the next local viewing session before that observer gate.
|
||||
**Currently working toward:** **M4 — "Live in the world." M3 — "Cast a
|
||||
spell" LANDED 2026-07-21.** Projectile/self-buff casting, protection effects,
|
||||
spellbook/component-book filtering and deletion, favorite spell bars, active
|
||||
effects, recall, portal-space presentation, and the complete two-client
|
||||
portal-out/materialization observer flow are user-gated. The R6 locomotion,
|
||||
collision, projectile, teleport, and radar rebaseline also passes. Deterministic
|
||||
world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon
|
||||
travel, same-location revisit, exact graceful disconnect, and uncapped
|
||||
fresh-process reconnect with canonical JSON/PNG evidence.
|
||||
|
||||
Before new M4 quest/emote/character-creation subsystem bodies enter the App
|
||||
layer, the active structural prerequisite is the behavior-preserving
|
||||
`GameWindow` decomposition in
|
||||
[`docs/architecture/code-structure.md`](../architecture/code-structure.md).
|
||||
The 2026-07-21 audit baseline was 15,723 lines, 278 fields, and 205 methods.
|
||||
Selection/interaction Slice 1, live-animation-presentation Slice 2, complete
|
||||
live-session ownership Slice 3, and live-entity App integration Slice 4 are
|
||||
complete. Slice 5 landblock presentation is also complete:
|
||||
the worker DAT transaction, render/physics/static publishers, and retryable
|
||||
Near/full retirement now have focused owners. The direct production cutover
|
||||
also serializes complete old-window retirement with teleport and session
|
||||
origin lifetimes. Slice 6 update-frame orchestration is complete: streaming,
|
||||
input, the object/network barrier, liveness, teleport, player mode, and camera
|
||||
presentation have typed phase owners, and `GameWindow.OnUpdate` is one
|
||||
orchestrator handoff. `GameWindow` is 7,026 raw lines / 241 fields / 108
|
||||
methods, down 8,697 lines (55.3%) from the campaign baseline. The full Release
|
||||
suite passes 7,182 tests / 5 skips; the 314-second capped/reconnect lifecycle
|
||||
gate and synchronized 394-second nine-destination resource soak both pass with
|
||||
graceful exits and stable owner counts. The completed Slice 6 ledger is
|
||||
[`2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`](2026-07-21-gamewindow-slice-6-update-frame-orchestration.md).
|
||||
Slice 7 draw-frame orchestration is complete. `GameWindow.OnRender` is one
|
||||
typed frame handoff; resource, world-scene, private-presentation, and
|
||||
diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is
|
||||
4,666 raw lines / 196 fields / 70 methods, down 11,057 lines (70.3%) from 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
|
||||
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
|
||||
preserve Slice 6 presentation. Final Slice 8 checkpoints A–H are complete:
|
||||
native callbacks and live-session composition now have explicit retryable
|
||||
owners. Checkpoint D adds the sole world-environment owner for clock, DAT sky,
|
||||
day group, weather, and AdminEnvirons state. Checkpoint E adds terminal,
|
||||
transactional ownership for Silk/dispatcher/retained/devtools input, the raw
|
||||
camera pointer, focus, and physical framebuffer size. Graceful live-session
|
||||
retirement now precedes physical callback removal. Checkpoint F adds the sole
|
||||
gameplay action-priority owner, typed combat/diagnostic/general commands, and a
|
||||
reversible retained-root item-drop binding. Checkpoint G adds the sole
|
||||
pre-window/runtime settings owner, one startup snapshot, ordered one-shot
|
||||
startup application, and inert late target binding. `GameWindow` is 3,663
|
||||
lines / 162 fields / 37 methods at G. Checkpoint H adds sole terrain/sky
|
||||
resource roots, one retained Host/runtime lease, an atomic update/render root
|
||||
pair, a prepared portal fallback/transfer transaction, and checked retryable GL
|
||||
leaf construction. `GameWindow` is 3,689 lines / 162 fields / 37 methods.
|
||||
Checkpoints I–L remain active. Issue #232
|
||||
tracks process-residency variance in the soak without loosening its leak
|
||||
threshold. Checkpoint H passes 61 focused ownership tests and 3,236 App tests /
|
||||
3 skips; the complete Release suite passes 7,606 tests / 5 skips and all three
|
||||
final corrected-diff reviews are clean.
|
||||
|
||||
Carried:
|
||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||
registered TS-50/TS-51 timing residuals (the complete-root-Frame/object-workset
|
||||
cutover is automated-test complete 2026-07-20; three independent re-reviews are
|
||||
clean, Release builds with 17 known test-project warnings tracked by #228, and
|
||||
6,457 tests pass / 5 skip), and
|
||||
registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset
|
||||
cutover is automated- and visual-gate complete; Release builds with 17 known
|
||||
test-project warnings tracked by #228, and 6,575 tests pass / 5 skip), and
|
||||
the deferred Modern Pipeline track (MP1b+). The separate #225 shared-alpha
|
||||
implementation has passed connected lifetime/performance gates and awaits only
|
||||
its lifestone/particle visual comparison.
|
||||
|
|
@ -658,8 +703,8 @@ include dungeons.
|
|||
publishes outdoor landcells independently of surviving object meshes.
|
||||
Terrain/entity streaming distance is
|
||||
unchanged; the user-requested default Extended setting doubles only particle
|
||||
range, while Retail remains available as the exact cutoff. The
|
||||
final two-client visual gate is still pending.
|
||||
range, while Retail remains available as the exact cutoff. The final
|
||||
two-client visual gate passed 2026-07-21.
|
||||
- **L.1c local attack receive path (implemented and live-gated 2026-07-15)** —
|
||||
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
|
||||
funnel and action-stamp gate, so ACE's server-selected melee/missile action
|
||||
|
|
@ -709,7 +754,17 @@ a game.
|
|||
|
||||
---
|
||||
|
||||
### M3 — "Cast a spell" — 🔵 ACTIVE (started 2026-07-15)
|
||||
### M3 — "Cast a spell" — ✅ LANDED 2026-07-21
|
||||
|
||||
**Landing writeup:** Mages now work end to end. A connected player can manage
|
||||
the retail spellbook/component book and favorite bars, cast projectile and
|
||||
self-targeted spells with the correct components/target gates, see traveling
|
||||
missiles and DAT-driven protection/impact effects, inspect live enchantments,
|
||||
and recall or portal through the retail tunnel/reveal lifecycle. The final
|
||||
two-client observer gate confirms departure disappearance, destination
|
||||
materialization, finished pose, rendered destination, and indoor movement
|
||||
without snap-back. The R6 local locomotion/collision/projectile/teleport and
|
||||
radar checks pass on the same Release baseline.
|
||||
|
||||
**Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation,
|
||||
the projectile, the impact. Self-cast a buff (Strength Self). See the
|
||||
|
|
@ -736,9 +791,9 @@ The strip now uses retail DAT states for link quality, Helpful/Harmful effects,
|
|||
Vitae, burden, mini-game availability, and end-character-session; live link
|
||||
age, player load, and enchantment registries drive their retail thresholds and
|
||||
the burden/effect/logout actions use the shared panel/dialog owners. Main-panel
|
||||
visibility now flows through retail's one-active `gmPanelUI` owner. M3 remains
|
||||
active only for the connected single-client magic
|
||||
UI/casting visual gate and the final two-client portal observer gate.
|
||||
visibility now flows through retail's one-active `gmPanelUI` owner. The
|
||||
connected single-client magic UI/casting gates and final two-client portal
|
||||
observer gate passed before landing.
|
||||
The 2026-07-17 indicator follow-up also mounts authored Character Information,
|
||||
Link Status, Vitae, and Mini Game detail roots as children of that same
|
||||
main-panel owner. Helpful, Harmful, Character Information, Link, and Vitae
|
||||
|
|
@ -791,7 +846,12 @@ from M3 = retail-equivalent gameplay loop for melee and casters.
|
|||
|
||||
---
|
||||
|
||||
### M4 — "Live in the world" — 🔵 (~6–10 weeks)
|
||||
### M4 — "Live in the world" — 🟡 NEXT (~6–10 weeks)
|
||||
|
||||
**Entry condition:** complete the behavior-preserving `GameWindow`
|
||||
decomposition campaign before adding H.3/H.4/L.1e feature bodies. This protects
|
||||
M4 from deepening the App-layer composition debt; it does not change M4's demo
|
||||
scope.
|
||||
|
||||
**Demo scenario:** Create a fresh character from scratch (no ACE admin).
|
||||
Spawn. Talk to an NPC. Accept a quest. Walk to a dungeon entrance. Portal
|
||||
|
|
|
|||
177
docs/plans/2026-07-20-automated-world-lifecycle-gate.md
Normal file
177
docs/plans/2026-07-20-automated-world-lifecycle-gate.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Automated world-lifecycle gate
|
||||
|
||||
**Status:** Complete (2026-07-20)
|
||||
**Owner:** M3 stabilization / R6 rendering-lifecycle rebaseline
|
||||
**Depends on:** `WorldRevealReadinessBarrier`, retained-UI automation,
|
||||
`FrameProfiler`, connected local ACE
|
||||
|
||||
## Outcome
|
||||
|
||||
Create a deterministic connected Release gate that catches the structural
|
||||
failures which previously required someone to stare at the client for several
|
||||
minutes:
|
||||
|
||||
- the first login frame cannot expose world geometry before the destination's
|
||||
render meshes, composite textures, and collision data are all ready;
|
||||
- every portal/recall lifetime crosses the same readiness edge, materializes
|
||||
once, and returns to the normal viewport once;
|
||||
- dungeon entry and exit use the same lifecycle without grey frames,
|
||||
stale-source geometry, or an uncollidable destination;
|
||||
- graceful logout followed by a fresh reconnect starts a new reveal lifetime
|
||||
and cannot inherit readiness or resources from the previous process;
|
||||
- repeated destinations do not cause cumulative memory, owner-count,
|
||||
allocation, update-time, or GPU-time growth;
|
||||
- each stable checkpoint produces machine-readable state plus a screenshot for
|
||||
later human comparison.
|
||||
|
||||
This is a correctness and resource-lifetime gate. Screenshots preserve visual
|
||||
evidence, but automation does **not** claim retail visual equivalence. Color,
|
||||
composition, animation feel, and subtle geometry artifacts remain user gates.
|
||||
|
||||
## Canonical runtime contract
|
||||
|
||||
`WorldRevealReadinessBarrier` remains the only definition of destination
|
||||
readiness. It will expose a structured snapshot rather than forcing telemetry
|
||||
to repeat the readiness formula. A snapshot records the destination cell,
|
||||
indoor/outdoor classification, required radius, render publication, composite
|
||||
texture readiness, collision readiness, an impossible/unhydratable claim, and
|
||||
the final `Ready` result.
|
||||
|
||||
A focused App-layer `WorldRevealLifecycleTelemetry` owns diagnostic reveal
|
||||
generations. It has no GL calls and no knowledge of `GameWindow`. For each
|
||||
login or portal generation it records:
|
||||
|
||||
1. begin;
|
||||
2. destination acquisition;
|
||||
3. each distinct readiness transition;
|
||||
4. the first frame where the normal world viewport is actually eligible to
|
||||
draw;
|
||||
5. completion or cancellation.
|
||||
|
||||
Making the normal viewport visible before `Ready` is a hard invariant failure,
|
||||
not a warning. A newer generation retires the older one, so stale asynchronous
|
||||
completion cannot satisfy the new destination.
|
||||
|
||||
`GameWindow` only supplies facts and invokes the controller at existing
|
||||
lifecycle seams. It does not gain a new feature body.
|
||||
|
||||
## Production automation seam
|
||||
|
||||
The retained-UI automation runner gains runtime commands through an injected
|
||||
interface:
|
||||
|
||||
- `wait world-ready [timeout-ms]` waits on canonical lifecycle state instead
|
||||
of a guessed sleep;
|
||||
- `wait materialized <occurrence> [timeout-ms]` waits for the authoritative
|
||||
portal completion count;
|
||||
- `checkpoint <name>` emits one JSON record containing reveal state,
|
||||
landblock/entity/animation and resource-owner counts, managed memory, and
|
||||
the latest frame-profiler summary;
|
||||
- `screenshot <name> [timeout-ms]` queues a default-framebuffer capture after
|
||||
the complete world and retained UI have drawn, then waits for the render
|
||||
thread to finish the PNG;
|
||||
- existing `input` commands perform deterministic turns and movement through
|
||||
`InputDispatcher`.
|
||||
|
||||
All state mutation and GL readback stay on the update/render thread. The
|
||||
external gate only launches the process, observes files/logs and OS process
|
||||
metrics, and requests normal `WM_CLOSE` shutdown.
|
||||
|
||||
## Connected route
|
||||
|
||||
The gate runs two capped/uncapped-aware sessions against local ACE:
|
||||
|
||||
1. fresh login → wait for canonical readiness → checkpoint + screenshot;
|
||||
2. outdoor dense scene → turn → stable checkpoint + screenshot;
|
||||
3. world-edge scene → turn → stable checkpoint;
|
||||
4. known dungeon destination → checkpoint + screenshot → local movement;
|
||||
5. return outdoors → checkpoint + screenshot;
|
||||
6. revisit the first dense scene → stable resource comparison;
|
||||
7. graceful close;
|
||||
8. reconnect the same account → repeat the fresh-login readiness and
|
||||
screenshot gate → graceful close.
|
||||
|
||||
The existing seven-destination R6 soak remains the longer performance route.
|
||||
The lifecycle gate is shorter and more diagnostic; it composes with that soak
|
||||
rather than replacing it.
|
||||
|
||||
## Hard failures
|
||||
|
||||
- world viewport observed before the active reveal snapshot is ready;
|
||||
- destination never reaches readiness or materialization within the bounded
|
||||
connected test timeout;
|
||||
- missing or duplicate reveal/materialization/completion edges;
|
||||
- missing checkpoint JSON or screenshot, zero-sized/corrupt screenshot;
|
||||
- absent required destination landblock/EnvCell/collision readiness;
|
||||
- retained per-owner resources after delete/session teardown in deterministic
|
||||
tests;
|
||||
- same-location resource counts or memory/per-frame allocation grow beyond the
|
||||
route's documented relative tolerance;
|
||||
- unhandled exception, access violation, OOM, GL/device loss, disconnect,
|
||||
unexpected WeenieError, nonzero exit, or failure of graceful shutdown.
|
||||
|
||||
Absolute FPS is recorded, never compared across local and RDP sessions. The
|
||||
gate compares like-for-like samples within one process and reports CPU and GPU
|
||||
frame time separately.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
1. Add a structured readiness snapshot and exhaustive barrier tests.
|
||||
2. Add lifecycle telemetry as a pure App owner with stale-generation,
|
||||
cancellation, duplicate, and early-visible tests.
|
||||
3. Add the automation runtime interface, deterministic waits, checkpoints, and
|
||||
screenshot request/completion protocol.
|
||||
4. Add the render-thread screenshot owner and structured resource snapshots.
|
||||
5. Wire the owners at login, F751 portal start, TAS placement/completion, world
|
||||
draw eligibility, and session teardown.
|
||||
6. Add the connected route and PowerShell report/gate orchestration.
|
||||
7. Run capped and uncapped Release gates, fix root causes, and retain artifacts.
|
||||
8. Extract the now-protected streaming/portal/reveal frame orchestration from
|
||||
`GameWindow` without changing the accepted lifecycle trace.
|
||||
9. Run focused tests, full Release build/test, connected gates, graceful-close
|
||||
verification, and documentation reconciliation.
|
||||
|
||||
## Completion evidence
|
||||
|
||||
- Focused App tests cover readiness composition, stale generations,
|
||||
cancellation, duplicate lifecycle edges, early-visible rejection, artifact
|
||||
capture, automation timeouts, and resource snapshots.
|
||||
- `dotnet build AcDream.slnx -c Release` succeeds. The complete repository
|
||||
suite is 6,480 passed / 5 skipped; the 17 existing test-project warnings
|
||||
remain tracked by issue #228.
|
||||
- The final two-process connected report passed with no failures. The capped
|
||||
process completed fresh login, Aerlinthe, Rynthid, Facility Hub, Holtburg,
|
||||
and an Aerlinthe revisit in 244.424 seconds. The uncapped process then
|
||||
reconnected the same account and completed in 61.717 seconds.
|
||||
- Both processes closed through `WM_CLOSE`, received the authoritative
|
||||
character-logoff confirmation, exited with code 0, and were matched to
|
||||
ACE's exact client UDP endpoint and `PacketHeader Disconnect` record.
|
||||
- Every checkpoint had zero pending live teardowns, landblock retirements,
|
||||
staged mesh uploads, composite warmups, and reveal invariant failures.
|
||||
- All six PNG artifacts were valid and inspected. Login, indoor dungeon,
|
||||
dungeon exit, Aerlinthe revisit, and reconnect showed complete world
|
||||
geometry rather than a grey or partially revealed destination.
|
||||
- The uncapped reconnect sampled 172.7 FPS / 5.79 ms at the checkpoint; its
|
||||
latest profiler window measured 5.7 ms CPU p50, 6.1 ms p95 and 5.7 ms GPU
|
||||
p50, 6.0 ms p95 with zero pacing time. This is machine-specific throughput
|
||||
evidence, not a cross-display FPS requirement.
|
||||
- The Aerlinthe revisit retained balanced owners and stayed inside the
|
||||
documented relative memory/resource tolerances. One circuit is not claimed
|
||||
as a permanent memory plateau proof; the longer seven-destination R6 soak
|
||||
remains the endurance gate.
|
||||
- `WorldRevealCoordinator` now owns the protected lifecycle outside
|
||||
`GameWindow`; the accepted trace did not change after extraction.
|
||||
|
||||
## Landed commits
|
||||
|
||||
- `db45b81f` — expose canonical readiness snapshots and lifecycle telemetry;
|
||||
- `354c2adc` — add deterministic checkpoints and framebuffer screenshots;
|
||||
- `b03371c0` — add the connected lifecycle/reconnect gate;
|
||||
- `68578fa5` — port retail graceful character logout and transport disconnect;
|
||||
- `a4ef5788` — centralize the reveal lifetime in `WorldRevealCoordinator`;
|
||||
- `f7b09617` — preserve singleton endpoint/checkpoint collections in the gate.
|
||||
|
||||
The remaining visual work is deliberately outside this gate: the R6
|
||||
locomotion/collision/projectile/teleport retail comparison, the two-client
|
||||
portal-out/materialization observer comparison, and issue #225's translucent
|
||||
lifestone/particle ordering comparison.
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
# GameWindow Slice 1 — selection and interaction ownership
|
||||
|
||||
**Status:** COMPLETE — landed 2026-07-21.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 1.
|
||||
**Baseline:** `cf50ee3d`; `GameWindow.cs` is 15,723 lines before this slice.
|
||||
**Behavior rule:** Existing retail behavior moves unchanged. Any defect found by
|
||||
the extraction audit is fixed in a separately identified commit before the
|
||||
mechanical cutover.
|
||||
|
||||
## Landing record
|
||||
|
||||
- Plan/oracle: `c2713837`.
|
||||
- Exact-incarnation lifetime hardening: `047a4c83`.
|
||||
- Retail pending keyboard-pickup presentation: `52dbb574`.
|
||||
- Read-only query extraction: `e74f2ca9`.
|
||||
- Stateful interaction owner: `fa8d5232`.
|
||||
- `GameWindow` cutover: `d2bb5af4`.
|
||||
- Three-review correction pass: `5acc3f01`.
|
||||
|
||||
The correction pass unified retail's one-inventory-request-at-a-time owner
|
||||
across every mutation surface, separated optimistic/rollback/authoritative
|
||||
placement notices, made response completion atomic and reentrancy-safe, and
|
||||
bound pending work to exact object identities and placement tokens.
|
||||
|
||||
Final metrics: `GameWindow.cs` 15,723 → 14,912 lines and 205 → 191 methods;
|
||||
fields remained 278. Release build passed, the focused selection/inventory
|
||||
gate passed, the full suite passed 6,558 tests with five intentional skips,
|
||||
and the retail-conformance, architecture/integration, and adversarial reviews
|
||||
all finished with no actionable findings.
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
`GameWindow` stops owning world selection and selection-driven interaction.
|
||||
After this slice it retains only composition and narrow lifecycle calls:
|
||||
|
||||
- construct the query/controller;
|
||||
- feed the completed render-selection frame;
|
||||
- drain outbound interactions at the existing retail frame boundary;
|
||||
- forward input actions and MoveTo completion;
|
||||
- forward entity hidden/removal and session-reset edges.
|
||||
|
||||
Two App owners replace the current body:
|
||||
|
||||
1. `WorldSelectionQuery` owns read-only world selection, eligibility,
|
||||
classification, naming, closest-hostile lookup, target bounds, and range
|
||||
queries over canonical live state.
|
||||
2. `SelectionInteractionController` owns selection intents, click routing,
|
||||
Use/PickUp ordering, speculative facing/movement, deferred close-range
|
||||
requests, and the input-action boundary.
|
||||
|
||||
Core `SelectionState` remains the sole selected-object truth.
|
||||
`ItemInteractionController` remains the sole retail ItemHolder policy owner.
|
||||
`LiveEntityRuntime` remains the sole GUID/incarnation/visibility owner.
|
||||
|
||||
## 2. Retail and project oracles
|
||||
|
||||
No AC algorithm is invented in this slice. Move the existing ports with these
|
||||
references attached:
|
||||
|
||||
- [`2026-07-17-retail-world-selection-pseudocode.md`](../research/2026-07-17-retail-world-selection-pseudocode.md)
|
||||
— render-coupled part selection, selection sphere, vivid target, click pulse.
|
||||
- [`2026-07-10-retail-toolbar-interaction-pseudocode.md`](../research/2026-07-10-retail-toolbar-interaction-pseudocode.md)
|
||||
— target-mode precedence, ItemHolder Use, selection notices.
|
||||
- [`2026-07-17-retail-external-container-looting-pseudocode.md`](../research/2026-07-17-retail-external-container-looting-pseudocode.md)
|
||||
— corpse/container open, range, pending loot placement.
|
||||
- [`2026-07-12-death-and-auto-target-pseudocode.md`](../research/2026-07-12-death-and-auto-target-pseudocode.md)
|
||||
— synchronous selection clear and reentrant Auto Target.
|
||||
- `Render::GfxObjUnderSelectionRay @ 0x0054C740`.
|
||||
- `ACCWeenieObject::SetSelectedObject @ 0x0058C2E0`.
|
||||
- `CPlayerSystem::OnAction @ 0x00561890`.
|
||||
- `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0`.
|
||||
- `ItemHolder::UseObject @ 0x00588A80`.
|
||||
- `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`.
|
||||
|
||||
Existing registered adaptations remain adaptations; this structural slice does
|
||||
not silently “improve” them:
|
||||
|
||||
- IA-19: automatic acquisition is narrowed to hostile non-player monsters.
|
||||
- AD-11: ACE null-useability fallback.
|
||||
- AD-27: natural `MoveToComplete` is the client completion seam.
|
||||
- AP-23: speculative local Use movement and 3/2/0.6-unit radius buckets.
|
||||
|
||||
Their file pointers move in the same commit as their implementation bodies.
|
||||
|
||||
## 3. Invariants
|
||||
|
||||
### 3.1 Render selection
|
||||
|
||||
- The renderer builds a private list, then publishes it only at
|
||||
`CompleteFrame`; input reads the previous complete frame.
|
||||
- A hit carries server GUID **and local entity ID**. The query accepts it only
|
||||
if `LiveEntityRuntime` still resolves the same current, visible top-level
|
||||
incarnation.
|
||||
- Plain selection excludes self. Active target mode includes self.
|
||||
- A successful hit starts the HIGH click-light pulse before target-mode or
|
||||
selection routing.
|
||||
- Target-mode success or rejection consumes the click and never falls through
|
||||
to ordinary selection/use.
|
||||
- A miss does not clear selection. A miss during target mode leaves target mode
|
||||
active.
|
||||
- Drag release may pulse the target, but does not select it.
|
||||
- Vivid target markers deliberately query materialized state so selected
|
||||
objects remain indicated through walls and long distances. Picking and new
|
||||
combat acquisition use visible interaction state only.
|
||||
|
||||
### 3.2 Input and outbound ordering
|
||||
|
||||
- Keep the one `InputDispatcher.Fired` subscription in `GameWindow`.
|
||||
- Combat attack handling, Press/DoubleClick filtering, and retained-UI capture
|
||||
occur before delegation exactly as today.
|
||||
- The controller handles only the currently implemented semantic actions:
|
||||
previous selection, closest monster, left selection, double-left selection,
|
||||
Use selected, and PickUp selected.
|
||||
- Input callbacks enqueue Use/PickUp. The queue drains after local movement
|
||||
output and before inbound packet dispatch.
|
||||
- The queue drains only its boundary snapshot; reentrant enqueue waits until
|
||||
the next frame.
|
||||
|
||||
### 3.3 Use/PickUp and movement
|
||||
|
||||
- `ItemInteractionController` decides Use/open/target mode. The new controller
|
||||
does not copy ItemHolder policy.
|
||||
- Far target: install speculative MoveTo and send exactly one wire request
|
||||
immediately. ACE owns its arrival callback.
|
||||
- Close target: install TurnToObject and defer the **first** request until
|
||||
natural `MoveToComplete(None)`.
|
||||
- Cancel/error completion never sends.
|
||||
- The movement intent preserves target cylinder radius/height, the AP-23 use
|
||||
radius, and the 7.5-unit CanCharge prediction.
|
||||
- Every queued/deferred request is bound to the captured live incarnation and
|
||||
revalidated before sending.
|
||||
- Pickup preserves destination container and placement. Retail pending-slot
|
||||
presentation occurs before the request.
|
||||
|
||||
### 3.4 Lifecycle
|
||||
|
||||
- Hidden, logical removal, session reset, and GUID replacement invalidate old
|
||||
hits and pending actions immediately.
|
||||
- Token-specific pending cleanup is allowed even after GUID replacement;
|
||||
GUID-only selection cleanup retains the existing incarnation guard so old
|
||||
teardown cannot clear a newly selected replacement.
|
||||
- Session reset atomically clears selection history, outbound queue, deferred
|
||||
action, item target mode/busy state, published selection frame, and click
|
||||
pulse.
|
||||
- `CombatTargetController` must not auto-acquire during `SessionReset`.
|
||||
- No new GUID map, selection store, visibility store, or retry timer is added.
|
||||
|
||||
## 4. Target interfaces
|
||||
|
||||
### 4.1 Render hit identity
|
||||
|
||||
`RetailSelectionPart` and `RetailSelectionHit` carry `LocalEntityId` alongside
|
||||
`ServerGuid`. `RetailSelectionScene.Pick` returns the complete hit. Its lighting
|
||||
source is also keyed by both identities so a reused GUID cannot inherit an old
|
||||
click pulse.
|
||||
|
||||
`RetailSelectionScene.Reset` clears building/published lists, frame keys,
|
||||
frustum state, and pulse state. Geometry resolution receives a narrow internal
|
||||
source interface so publication behavior is testable without installed DAT.
|
||||
|
||||
### 4.2 `WorldSelectionQuery`
|
||||
|
||||
The query receives:
|
||||
|
||||
- canonical `LiveEntityRuntime`;
|
||||
- `ClientObjectTable`;
|
||||
- the shared `RetailSelectionScene`;
|
||||
- player GUID and current player position providers;
|
||||
- camera/viewport and cursor providers;
|
||||
- a DAT selection-sphere resolver;
|
||||
- the existing Setup-cylinder resolver.
|
||||
|
||||
It exposes read-only operations for:
|
||||
|
||||
- pick at cursor/screen point with exact-incarnation validation;
|
||||
- current interaction record/entity lookup;
|
||||
- item type, name, and description;
|
||||
- creature/hostile/health eligibility;
|
||||
- closest visible hostile result without mutating selection;
|
||||
- combat camera target;
|
||||
- vivid target information and selection sphere;
|
||||
- useability/pickupability;
|
||||
- AP-23 use radius, close-range, CanCharge, and target movement data;
|
||||
- external-container cylinder range.
|
||||
|
||||
The query never mutates selection, queues work, sends packets, or starts
|
||||
movement.
|
||||
|
||||
### 4.3 `SelectionInteractionController`
|
||||
|
||||
The controller receives:
|
||||
|
||||
- `SelectionState` and `WorldSelectionQuery`;
|
||||
- `ItemInteractionController` provider;
|
||||
- a typed Use/PickUp transport;
|
||||
- a narrow movement-intent sink;
|
||||
- toast/system diagnostic sinks;
|
||||
- Auto Target setting provider.
|
||||
|
||||
It owns `OutboundInteractionQueue` and an incarnation-bound
|
||||
`PendingPostArrivalAction`. Public methods form the complete host seam:
|
||||
|
||||
```text
|
||||
HandleInputAction
|
||||
PickAtCursor / PlaceDraggedItem
|
||||
SelectClosestCombatTarget
|
||||
GetSelectedOrClosestCombatTarget
|
||||
SendUse / SendPickUp
|
||||
DrainOutbound
|
||||
OnNaturalMoveToComplete
|
||||
OnEntityHidden / OnEntityRemoved
|
||||
ResetSession
|
||||
```
|
||||
|
||||
The controller does not subscribe directly to Silk input and does not own
|
||||
rendering, combat state, the session, or item policy.
|
||||
|
||||
## 5. Execution sequence
|
||||
|
||||
### Commit 0 — plan and defect registration
|
||||
|
||||
- Land this plan.
|
||||
- Register #230 for GUID-incarnation/session-reset leakage.
|
||||
- Register #231 for F-key pickup missing the pending destination reservation.
|
||||
- No runtime behavior change.
|
||||
|
||||
### Commit 1 — lifecycle identity hardening (#230)
|
||||
|
||||
- Add local-entity identity to render parts/hits and selection lighting.
|
||||
- Add scene reset and deterministic publication tests.
|
||||
- Validate published hits against current visible incarnation.
|
||||
- Bind pending post-arrival actions to a live record/local identity.
|
||||
- Clear only the matching pending token on teardown, including GUID reuse.
|
||||
- Make session reset clear scene and item interaction state.
|
||||
- Prevent combat Auto Target during `SessionReset`.
|
||||
- Keep this root-cause correction independently bisectable.
|
||||
|
||||
### Commit 2 — retail pending pickup presentation (#231)
|
||||
|
||||
- Add one ItemInteractionController entry point for a validated world pickup.
|
||||
- Publish `PendingBackpackPlacementRequested` before calling the existing
|
||||
placement delegate.
|
||||
- Route F-key pickup through it with destination/placement unchanged.
|
||||
- Do not optimistically move ownership; server confirmation/failure remains
|
||||
authoritative.
|
||||
|
||||
### Commit 3 — extract `WorldSelectionQuery`
|
||||
|
||||
- Move every read-only selection/classification/range body from `GameWindow`.
|
||||
- Preserve materialized-versus-visible semantics.
|
||||
- Extend the live animation read seam with current motion so dead eligibility
|
||||
does not depend on `GameWindow.AnimatedEntity`.
|
||||
- Rewire cursor, vivid target, toolbar health, combat camera, external-container
|
||||
range, drag target, and door-name reads through the query.
|
||||
- Delete the corresponding `GameWindow` methods.
|
||||
|
||||
### Commit 4 — extract `SelectionInteractionController`
|
||||
|
||||
- Move world click routing, selection mutation, closest-target selection,
|
||||
outbound queue, Use/PickUp, movement intent, deferred completion, and
|
||||
hidden/removal/reset handling.
|
||||
- Compose `ItemInteractionController`; do not duplicate its policies.
|
||||
- Update IA-19/AD-11/AD-27/AP-23 file pointers.
|
||||
- Add focused controller tests for every invariant in §3.
|
||||
|
||||
### Commit 5 — input cutover and host cleanup
|
||||
|
||||
- Replace the six selection/interaction switch cases with one controller
|
||||
delegation call at the same ordered position.
|
||||
- Forward render-frame, update-frame, MoveTo completion, hidden/removal,
|
||||
session-reset, cursor, and drag hooks.
|
||||
- Delete old fields/records/wrappers from `GameWindow`.
|
||||
- Prove `GameWindow` no longer owns a selection algorithm or interaction
|
||||
request body.
|
||||
|
||||
### Commit 6 — review, gate, and ledger close
|
||||
|
||||
- Run retail-conformance, architecture/integration, and adversarial read-only
|
||||
reviews against the complete slice diff.
|
||||
- Fix confirmed findings and repeat review until clean.
|
||||
- Run focused Core/App tests, Release solution build, and full Release suite.
|
||||
- Update code-structure ownership status, roadmap/current queue if necessary,
|
||||
both session instruction files, divergence pointers, and durable interaction
|
||||
memory.
|
||||
- Record the new `GameWindow` line/field/method counts.
|
||||
|
||||
## 6. Automated acceptance matrix
|
||||
|
||||
### Render/query
|
||||
|
||||
- Building frame is invisible until `CompleteFrame`.
|
||||
- Last complete frame remains queryable while the next frame builds.
|
||||
- Empty completion clears prior hits.
|
||||
- Hidden, pending, deleted, and replaced incarnations reject stale hits.
|
||||
- A replacement GUID is not pickable until its own part is published.
|
||||
- Pulse is identity-bound and cannot color a replacement.
|
||||
- Plain pick excludes self; target-mode pick includes self.
|
||||
- Vivid target can use materialized state while combat candidates cannot.
|
||||
- Closest hostile excludes self, players, pets, friendly NPCs, dead, Hidden,
|
||||
pending, and deleted objects.
|
||||
|
||||
### Interaction
|
||||
|
||||
- Pulse precedes target-mode and selection routing.
|
||||
- Target-mode success/rejection consumes the click without selection drift.
|
||||
- Single click selects; double click captures the clicked GUID and queues one
|
||||
activation.
|
||||
- Miss preserves selection and target mode.
|
||||
- Previous selection preserves current SelectionState semantics.
|
||||
- Queue drains after movement and only once per captured boundary.
|
||||
- Far Use/PickUp sends once; close sends only on natural completion.
|
||||
- Cancel/error/Hidden/delete/reset sends zero.
|
||||
- Delete then same-GUID create sends zero to the replacement.
|
||||
- Useability and pickupability fallbacks remain byte-for-byte behaviorally
|
||||
equivalent.
|
||||
- Creature and Stuck pickup rejection messages remain exact.
|
||||
- Corpse double-click/Use opens it and never attempts to pick up the corpse.
|
||||
- F pickup reserves the first destination slot before wire send.
|
||||
- Pickup destination/placement survive the deferred path.
|
||||
|
||||
### Lifecycle/input
|
||||
|
||||
- Session reset clears previous selection, target mode, busy state, queue,
|
||||
pending completion, published picks, and pulse; repeated reset is safe.
|
||||
- SessionReset cannot trigger combat auto-target.
|
||||
- Modal/retained UI capture still prevents controller invocation upstream.
|
||||
- Escape target-mode cancellation remains ahead of window-close behavior.
|
||||
|
||||
## 7. Connected gate
|
||||
|
||||
Run one normal capped Release client and verify:
|
||||
|
||||
1. select a nearby door/NPC and see the click pulse and target corners;
|
||||
2. double-click/Use the door or NPC;
|
||||
3. select a distant corpse, press R, approach, and open it;
|
||||
4. double-click one loot item and use F on another; both reserve the expected
|
||||
backpack slot and settle after ACE confirmation;
|
||||
5. enter combat, select a hostile, use closest-target and previous-selection;
|
||||
6. kill the selected target with Auto Target enabled and observe only a hostile
|
||||
replacement;
|
||||
7. portal or relog and confirm no old selection/pulse/action survives.
|
||||
|
||||
Any failure returns to the owning commit. No retry, grace period, or
|
||||
GUID-only suppression is acceptable.
|
||||
|
||||
## 8. Subagent policy for this slice
|
||||
|
||||
The implementation is tightly coupled across `GameWindow`, live lifetime,
|
||||
render publication, input order, and player MoveTo. One primary agent performs
|
||||
all edits. Independent subagents are useful for read-only retail,
|
||||
architecture, and adversarial reviews; they do not edit the shared slice
|
||||
concurrently.
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
# GameWindow Slice 2 — live animation presentation ownership
|
||||
|
||||
**Status:** Complete 2026-07-21. Automated and three-agent review gates pass;
|
||||
connected visual regression gate is carried to the final campaign gate.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 2.
|
||||
**Baseline:** `9ad8113c`; `GameWindow.cs` is 14,912 lines, 278 fields, and
|
||||
191 methods before this slice.
|
||||
**Result:** `GameWindow.cs` is 14,546 lines, 277 fields, and 190 methods.
|
||||
The slice also closed stale-schedule ABA across replacement, appearance
|
||||
rebinding, static rebind, and borrowed sequencer buffers. Forty-one focused
|
||||
Release tests and the full 6,575-pass / 5-skip Release suite are green.
|
||||
**Behavior rule:** This is an ownership extraction over the accepted R6 object
|
||||
frame plus one named-retail conformance correction: short AnimFrames retain
|
||||
the prior/rest pose of trailing CPartArray parts. It must not otherwise change
|
||||
sequence time, root motion, collision, manager order, hook semantics, part
|
||||
transforms, render eligibility, or effect attachment.
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
`GameWindow` stops owning final animated-part presentation. A focused
|
||||
`LiveEntityAnimationPresenter` consumes the already-completed scheduler output
|
||||
and publishes the exact same draw and effect state:
|
||||
|
||||
- final drawable `MeshRef` transforms;
|
||||
- indexed rigid part poses and availability;
|
||||
- `EntityEffectPoseRegistry` publication;
|
||||
- live static-animation frame handoff;
|
||||
- legacy animation interpolation state;
|
||||
- one-time `MotionDone` binding;
|
||||
- presentation-specific sequence/part diagnostics.
|
||||
|
||||
The shipped owners remain unchanged:
|
||||
|
||||
- `LiveEntityAnimationScheduler` owns ordinary-object clocks, PartArray
|
||||
advance, root motion, one movement owner, control-hook capture, and the
|
||||
retail manager tail;
|
||||
- `RetailStaticAnimatingObjectScheduler` owns the separate retail
|
||||
`static_animating_objects` time/workset and its deferred process-hooks tail;
|
||||
- `EquippedChildRenderController` owns equipped-child composition;
|
||||
- `AnimationHookFrameQueue` owns incarnation-scoped semantic/presentation-hook
|
||||
delivery;
|
||||
- `LiveEntityRuntime` remains the only identity, incarnation, component, and
|
||||
spatial-lifetime owner.
|
||||
|
||||
`GameWindow.AdvanceLiveObjectRuntimeCore` retains only the explicit frame-order
|
||||
calls between these owners.
|
||||
|
||||
## 2. Retail and project oracles
|
||||
|
||||
No new AC algorithm is introduced. The extraction is pinned to:
|
||||
|
||||
- [`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md)
|
||||
— `UpdatePositionInternal`, `process_hooks`, and manager-tail ordering.
|
||||
- [`2026-07-19-r6-complete-root-frame-pseudocode.md`](../research/2026-07-19-r6-complete-root-frame-pseudocode.md)
|
||||
— complete root `Frame`, ordinary/static worksets, part/effect publication.
|
||||
- [`memory/project_animation_runtime.md`](../../memory/project_animation_runtime.md)
|
||||
— appearance-update and description-before-enter-world lifetime invariants.
|
||||
- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`.
|
||||
- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`.
|
||||
- `CPhysicsObj::process_hooks @ 0x00511550`.
|
||||
- `CPhysicsObj::animate_static_object @ 0x00513DF0`.
|
||||
- `CPartArray::Update @ 0x00517DB0`.
|
||||
- `CPartArray::UpdateParts @ 0x005190F0`.
|
||||
- `CPartArray::AnimationDone @ 0x00517D30`.
|
||||
- `CPhysicsObj::MotionDone @ 0x0050FDB0`.
|
||||
- `Frame::combine @ 0x005122E0`.
|
||||
|
||||
ACE/DatReaderWriter and the installed retail DAT remain secondary fixtures for
|
||||
motion-table/animation schema interpretation. Named retail is authoritative
|
||||
for ordering and transform semantics.
|
||||
|
||||
## 3. Fixed frame-order contract
|
||||
|
||||
One ordinary live-object frame remains:
|
||||
|
||||
```text
|
||||
local player pre-network object phase
|
||||
selection outbound boundary
|
||||
publish PhysicsScript clock
|
||||
LiveEntityAnimationScheduler.Tick
|
||||
RetailStaticAnimatingObjectScheduler.Tick
|
||||
LiveEntityAnimationPresenter.Present
|
||||
EquippedChildRenderController.Tick
|
||||
RetailStaticAnimatingObjectScheduler.ProcessHooks
|
||||
translucency advance
|
||||
AnimationHookFrameQueue.Drain
|
||||
effect-root refresh
|
||||
attached-emitter refresh
|
||||
live-light refresh
|
||||
particle visibility
|
||||
particle simulation
|
||||
PhysicsScript simulation
|
||||
```
|
||||
|
||||
The presenter advances no clock and drains no hook. It only consumes poses
|
||||
produced earlier in the same frame. Static `process_hooks` remains after live
|
||||
parts and equipped children, as retail's `animate_static_object` requires.
|
||||
|
||||
## 4. Ownership and interfaces
|
||||
|
||||
### 4.1 Animation component state
|
||||
|
||||
Move `GameWindow.AnimatedEntity` and `GameWindow.AnimatedPartTemplate` to
|
||||
top-level App rendering types:
|
||||
|
||||
```text
|
||||
LiveEntityAnimationState
|
||||
LiveAnimationPartTemplate
|
||||
```
|
||||
|
||||
`LiveEntityAnimationState` remains the exact component stored in
|
||||
`LiveEntityRecord.AnimationRuntime`. It owns no GUID map and no lifetime. It
|
||||
retains:
|
||||
|
||||
- the exact `WorldEntity`, Setup, Animation, and optional sequencer;
|
||||
- legacy low/high/current frame and framerate;
|
||||
- ObjScale, part template, and part availability;
|
||||
- prepared local-player pose and complete root-frame scratch;
|
||||
- reusable `MeshRefs`, effect-part-pose, and diagnostics scratch.
|
||||
|
||||
Appearance updates mutate this same component and preserve sequencer/playback
|
||||
state. Logical replacement creates a new component.
|
||||
|
||||
### 4.2 Presentation context
|
||||
|
||||
Define a narrow App interface equivalent to:
|
||||
|
||||
```csharp
|
||||
interface ILiveAnimationPresentationContext
|
||||
{
|
||||
uint LocalPlayerGuid { get; }
|
||||
MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record);
|
||||
}
|
||||
```
|
||||
|
||||
`GameWindow` implements only these constant-time lookups. The presenter cannot
|
||||
reach windowing, GL, networking, input, streaming, physics, or arbitrary
|
||||
`GameWindow` methods.
|
||||
|
||||
### 4.3 Presenter
|
||||
|
||||
Use an API equivalent to:
|
||||
|
||||
```csharp
|
||||
void Present(
|
||||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> schedules);
|
||||
|
||||
void PrepareAnimation(
|
||||
LiveEntityRecord record,
|
||||
LiveEntityAnimationState state);
|
||||
```
|
||||
|
||||
The presenter receives:
|
||||
|
||||
- canonical `LiveEntityRuntime` provider for exact-record validation;
|
||||
- `EntityEffectPoseRegistry`;
|
||||
- `ILiveAnimationPresentationContext`;
|
||||
- a late-bound `ILiveStaticPartFrameSource` (the static scheduler is created
|
||||
after the presenter);
|
||||
- startup-resolved hidden-part diagnostic index;
|
||||
- typed animation diagnostic flags.
|
||||
|
||||
The presenter owns its own reusable `LiveEntityRecord` snapshot. It does not
|
||||
borrow `LiveEntityAnimationRuntimeView`'s reusable enumerator snapshot across
|
||||
the synchronous `EffectPoseChanged` callback boundary. Nested enumeration by a
|
||||
subscriber therefore cannot invalidate the outer presentation pass.
|
||||
|
||||
Each schedule carries the exact `LiveEntityRecord`, `WorldEntity`, animation
|
||||
component, `ObjectClockEpoch`, and projection-mutation version which produced
|
||||
it. The presenter validates all of those identities before consuming ordinary
|
||||
or static frames and again before each externally observable publication. A
|
||||
dictionary key/local ID is an index only; it is never proof of incarnation.
|
||||
The static prepared-frame source is queried late, only after this validation.
|
||||
Its take operation receives the exact record, entity, animation component,
|
||||
object-clock epoch, and projection/residency versions; it validates those
|
||||
tokens before clearing and returning prepared frames.
|
||||
|
||||
It keeps no GUID map, no animation clock, no packet/session reference, and no
|
||||
render-backend object.
|
||||
|
||||
### 4.4 Diagnostics
|
||||
|
||||
Move the surviving `SEQSTATE`, `CURRNODE`, `PARTSDIAG`, and `MOTIONDONE` gates
|
||||
behind one typed animation diagnostic owner. Environment variables are read
|
||||
once; the presenter does not perform per-frame environment lookups. Throttle
|
||||
state follows the exact animation component lifetime instead of living on the
|
||||
remote-motion component.
|
||||
|
||||
## 5. Transform invariants
|
||||
|
||||
For every part index, preserve these two deliberately different transforms.
|
||||
|
||||
### Drawable visual transform
|
||||
|
||||
```text
|
||||
Setup DefaultScale
|
||||
× animated part orientation
|
||||
× animated part translation
|
||||
× entity ObjScale
|
||||
```
|
||||
|
||||
This is the existing renderer/hydration contract. It scales geometry and the
|
||||
part's translated placement.
|
||||
|
||||
### Rigid effect/attachment transform
|
||||
|
||||
```text
|
||||
animated part orientation
|
||||
× translation(animated origin × entity ObjScale)
|
||||
```
|
||||
|
||||
Retail `CPartArray::UpdateParts` scales the part-frame origin only. Setup
|
||||
DefaultScale is `gfxobj_scale`, not rigid part-frame state, so particles,
|
||||
lights, attachments, and multipart collision must never inherit it.
|
||||
|
||||
Additional invariants:
|
||||
|
||||
- Sequencer frames win when present. Retail updates only
|
||||
`min(CPartArray.num_parts, AnimFrame.num_parts)`; missing trailing frames
|
||||
retain both their previous drawable visual transform and their previous
|
||||
indexed rigid/effect pose (the two distinct initial rest transforms on first
|
||||
presentation) instead of snapping either channel to identity.
|
||||
- Legacy animation interpolates origin linearly and orientation spherically,
|
||||
clamps interpolation to `[0,1]`, and wraps over the inclusive frame span.
|
||||
- Live static prepared frames override the ordinary schedule for that owner.
|
||||
- `ComposeParts == false` changes no mesh or indexed pose.
|
||||
- Non-drawable/debug-hidden parts remain in indexed effect poses but not
|
||||
drawable `MeshRef`s.
|
||||
- `PartAvailability` is published unchanged and controls effect part lookup.
|
||||
- Mesh identity, surface overrides, and list reuse remain unchanged.
|
||||
- Entity mesh/indexed-part state is installed before effect-pose publication.
|
||||
|
||||
## 6. MotionDone invariants
|
||||
|
||||
- Bind before any sequencer can complete; repeated calls are idempotent.
|
||||
- The scheduler validates the exact owner before invoking `PrepareAnimation`.
|
||||
- Resolve the player/remote `MotionInterpreter` at callback time because that
|
||||
consumer may be created after the animation component.
|
||||
- Never capture a legitimate null interpreter permanently.
|
||||
- Never complete a motion from pose publication; `AnimationHookFrameQueue`
|
||||
remains the semantic `AnimationDone` boundary.
|
||||
- Logical removal/replacement stops the old component from advancing; no
|
||||
presenter-owned callback queue survives it.
|
||||
- The callback revalidates the stable captured record, entity, and animation
|
||||
component before resolving a same-GUID interpreter. It deliberately does
|
||||
not capture object-clock/projection epochs: the same retail CPartArray and
|
||||
completion consumer survive legitimate rebucketing and leave/re-enter.
|
||||
GUID reuse still cannot deliver an old completion to a replacement.
|
||||
- `MOTIONDONE` diagnostics observe but never alter queue state.
|
||||
|
||||
## 7. Execution sequence
|
||||
|
||||
### Commit 0 — plan and baseline
|
||||
|
||||
- Land this plan.
|
||||
- Record exact baseline metrics and current frame order.
|
||||
- Register no new divergence; this slice moves an accepted mechanism.
|
||||
|
||||
### Commit 1 — extract component types and pure presenter
|
||||
|
||||
- Move animation component/template types out of `GameWindow`.
|
||||
- Add the presentation context and typed diagnostics owner.
|
||||
- Implement `LiveEntityAnimationPresenter` by moving the current composition
|
||||
body line-for-line, retaining the baseline trailing-part fallback initially.
|
||||
- Add focused tests before runtime cutover.
|
||||
|
||||
### Commit 2 — cut over runtime ownership
|
||||
|
||||
- Construct the presenter beside the scheduler.
|
||||
- Route scheduler preparation, local-player pre-advance, spawn-time binding,
|
||||
and final presentation through it.
|
||||
- Delete `TickAnimations`, `EnsureMotionDoneBinding`, nested animation types,
|
||||
and presentation-only remote diagnostic fields from `GameWindow`.
|
||||
- Keep child/static-hook/effect ordering visible in
|
||||
`AdvanceLiveObjectRuntimeCore`.
|
||||
|
||||
### Commit 3 — retail trailing-part conformance
|
||||
|
||||
- Record the `CPartArray::UpdateParts` minimum-count/retain behavior in the
|
||||
R6 pseudocode note.
|
||||
- Preserve both visual and rigid prior/rest trailing transforms.
|
||||
- Add first-presentation and later-shorter-frame conformance tests plus
|
||||
ordinary/live-static/DAT-static parity coverage.
|
||||
- Update the divergence register in this commit if the audited register
|
||||
contains a corresponding row; introduce no silent behavior delta.
|
||||
|
||||
### Commit 4 — review, gate, and ledger close
|
||||
|
||||
- Run retail-conformance, architecture/integration, and adversarial read-only
|
||||
reviews against the complete slice.
|
||||
- Fix every confirmed finding and repeat review until clean.
|
||||
- Run focused animation, scheduler, static, effect, projectile, remote-motion,
|
||||
and local-player tests.
|
||||
- Run Release solution build and full Release suite.
|
||||
- Record new `GameWindow` line/field/method counts.
|
||||
- Update code structure, roadmap, milestones, issues if any, durable animation
|
||||
memory, `AGENTS.md`, and `CLAUDE.md`.
|
||||
|
||||
## 8. Automated acceptance matrix
|
||||
|
||||
### Composition
|
||||
|
||||
- Sequencer pose composes every part and preserves surface overrides.
|
||||
- Missing sequencer trailing parts retain their previous/rest indexed pose.
|
||||
- Legacy current/next frame interpolation and inclusive wrapping are exact.
|
||||
- Legacy hidden/single-frame owners compose only when scheduled.
|
||||
- Static prepared frames override ordinary frames exactly once.
|
||||
- `ComposeParts == false` preserves prior mesh/pose state.
|
||||
|
||||
### Visual versus rigid transforms
|
||||
|
||||
- Setup nonuniform DefaultScale affects drawable geometry only.
|
||||
- ObjScale affects drawable geometry and translated placement.
|
||||
- Rigid effect poses scale only the translated origin.
|
||||
- Nondrawable and debug-hidden parts remain available to effects.
|
||||
- Unavailable parts remain indexed but fail effect-part lookup.
|
||||
|
||||
### Lifetime and ordering
|
||||
|
||||
- A removed/replaced animation component is skipped by the runtime-view
|
||||
snapshot and cannot publish into its replacement.
|
||||
- A schedule prepared for incarnation A is rejected if A is withdrawn,
|
||||
replaced, or reprojected before presentation, including same-local-ID ABA.
|
||||
- Reentrant deletion/replacement during pose publication prevents every later
|
||||
write for the stale owner; no borrowed scratch buffer crosses a callback.
|
||||
- Effect-pose publication sees the entity's already-installed indexed parts.
|
||||
- Same-frame animation hooks drain only after current parts and children.
|
||||
- Static process-hooks runs only after static parts and children are current.
|
||||
- Presentation allocates no per-frame mesh/effect lists in steady state.
|
||||
|
||||
### Motion completion
|
||||
|
||||
- Binding is idempotent.
|
||||
- A consumer created after binding receives completion.
|
||||
- Player and remote resolution select the correct interpreter.
|
||||
- Missing consumer is a no-op.
|
||||
- Diagnostics do not duplicate `MotionDone`.
|
||||
- A legitimate rebucket/object-clock rebase preserves the binding.
|
||||
- Delete plus GUID/local-ID reuse rejects completion from the old component.
|
||||
|
||||
### Cross-owner order and reconciliation
|
||||
|
||||
The presenter-level portions below are covered by Slice 2. The full
|
||||
instrumented owner-order trace and non-advancing spatial-reconcile assertion
|
||||
remain assigned to Slice 6, where the update sequence becomes one testable
|
||||
orchestrator instead of a `GameWindow` method. Slice 2 preserves that order but
|
||||
does not claim an end-to-end orchestrator test prematurely.
|
||||
|
||||
- One instrumented object-frame test pins presenter, children, static hooks,
|
||||
fades, ordinary hook drain, effect roots, emitters, lights, particles, and
|
||||
scripts. A PES-created particle first simulates on the following frame.
|
||||
- Authoritative spatial reconciliation performs only root/effect refresh,
|
||||
child composition, emitter refresh, and light refresh; it advances no
|
||||
animation, hook, fade, particle, script, or object clock.
|
||||
- Appearance shrink from three parts to one preserves playback while removing
|
||||
stale MeshRefs, indexed poses, availability, and effect-registry entries.
|
||||
- Ordinary, live-static handoff, and DAT-static composition share conformance
|
||||
fixtures for scale, sparse availability, and surface overrides.
|
||||
- An equipped child consumes the just-published parent indexed pose in the
|
||||
same frame, including ObjScale and nested attachment composition.
|
||||
- An animated projectile's trail/light anchor observes the same-quantum root
|
||||
and part pose, never the previous frame.
|
||||
|
||||
`EquippedChildRenderController` callback-mutation hardening is an adjacent
|
||||
Slice-6 frame-orchestration concern. Slice 2 does not claim it: the known
|
||||
reentrancy case is recorded as a follow-up unless a focused test demonstrates
|
||||
the existing traversal is already safe.
|
||||
- A nested animation-runtime enumeration triggered by `EffectPoseChanged`
|
||||
cannot overwrite or truncate the presenter's private outer snapshot.
|
||||
|
||||
## 9. Connected gate
|
||||
|
||||
Run one normal capped Release client and verify behavior remains unchanged:
|
||||
|
||||
1. local idle, walk, run, strafe, turn, jump, combat, and stop transitions;
|
||||
2. observe a remote player walk/run/turn without pose stalls or blips;
|
||||
3. open/close a door and kill a creature; static/reactive final poses persist;
|
||||
4. equip/unequip armor and switch melee/bow/wand without animation loss;
|
||||
5. fire an arrow and cast a projectile spell; trails remain attached to the
|
||||
current missile/hand pose;
|
||||
6. cast a protection and recall/portal; body effects follow current parts;
|
||||
7. portal/relog and confirm no old pose or completion reaches a replacement.
|
||||
|
||||
Any failure returns to the owning commit. No fallback pose cache, delayed
|
||||
retry, grace period, or GUID-only suppression is acceptable.
|
||||
|
||||
## 10. Subagent policy
|
||||
|
||||
One primary agent performs all edits. Read-only agents independently audit
|
||||
retail conformance, architecture/lifetime, and adversarial tests before the
|
||||
plan and after every implementation correction. No subagent edits the shared
|
||||
slice concurrently.
|
||||
414
docs/plans/2026-07-21-gamewindow-slice-3-live-session.md
Normal file
414
docs/plans/2026-07-21-gamewindow-slice-3-live-session.md
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
# GameWindow Slice 3 — complete live-session ownership
|
||||
|
||||
**Status:** Complete 2026-07-21.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 3.
|
||||
**Baseline:** `9a150e24`; `GameWindow.cs` is 14,546 lines, 277 fields, and
|
||||
190 methods before this slice.
|
||||
**Behavior rule:** Preserve the accepted connect, EnterWorld, inbound dispatch,
|
||||
chat/command, and retail graceful-close behavior while correcting the
|
||||
named-retail character-list, unattended-selection, canonical-account, and
|
||||
disconnect-state gaps discovered by the slice audit. This slice changes App
|
||||
ownership and fixes proven session-lifetime defects; it does not add a retained
|
||||
character-selection UI or redesign the transport.
|
||||
|
||||
**Progress ledger:**
|
||||
|
||||
- [x] A0 — retail lifecycle conformance corrections (`aea957f8`): retail
|
||||
active/deleted CharacterSet parsing, validated unattended selection,
|
||||
canonical-account F657, exact F653 confirmation, negotiated-state
|
||||
Disconnect symmetry, closed-channel/flood-safe confirmation wait, and
|
||||
deterministic shutdown tests. Release: 6,605 passed / 5 skipped.
|
||||
- [x] A — owned subscription primitives (`7d452aa6`): exact nested
|
||||
GameEvent registration tokens, reverse/idempotent subscription ownership,
|
||||
transactional wiring construction, and adversarial unregister,
|
||||
reentrancy, replacement, and concurrent-dispose coverage. Release: 6,621
|
||||
passed / 5 skipped.
|
||||
- [x] B — session event and command routers (`961bdd07`): focused inbound
|
||||
event and outbound command owners, exact reverse teardown, copied/in-flight
|
||||
callback acceptance gates, inactive-before-EnterWorld publication,
|
||||
concurrent and reentrant disposal serialization, and delayed-dialog
|
||||
indirection that makes displaced transports both inert and collectible.
|
||||
Three-agent review clean. Release: 6,636 passed / 5 skipped.
|
||||
- [x] C — convergent session reset (`4f31a508`): fixed ordered manifest,
|
||||
all-attempt named transaction, retry-observable owner resets, canonical
|
||||
live-runtime convergence/identity gate, retained-world preservation,
|
||||
retail dialog completion semantics, and actual-owner A → failed drain →
|
||||
retry → clean-state coverage. Three-agent review clean. Release: 6,659
|
||||
passed / 5 skipped.
|
||||
- [x] D — complete lifecycle controller (`d9ccf8a6`): sole App ownership
|
||||
of resolve/create/bind/Connect/select/EnterWorld/Tick/stop/reconnect,
|
||||
generation-gated reentrancy, pre-publication command activation,
|
||||
convergent staged teardown, transport-free deterministic session tests,
|
||||
and invalid-endpoint validation before UDP allocation. Three-agent review
|
||||
clean. Release: 6,707 passed / 5 skipped.
|
||||
- [x] E — GameWindow cutover (`6a5d9e2e`): one controller field, focused
|
||||
lifecycle host, transactional event/command binding, call-time borrowed
|
||||
session resolution, controller-backed retained/dev UI command providers,
|
||||
unified shutdown, and structural regression gates proving the displaced
|
||||
fields and lifecycle bodies remain absent. Three-agent review clean.
|
||||
Release: 6,714 passed / 5 skipped.
|
||||
- [x] F — final review, connected gate, and documentation closeout. All three
|
||||
independent review tracks finished clean. Release: 6,714 passed / 5 skipped.
|
||||
The 306-second connected lifecycle gate passed capped login, five travel/
|
||||
revisit checkpoints, exact graceful exit, and uncapped fresh-process
|
||||
reconnect; both client processes exited normally. The sole warning was 25
|
||||
expected world-edge landblock misses already classified by the gate.
|
||||
|
||||
## 1. Outcome
|
||||
|
||||
`LiveSessionController` becomes the sole App owner of one exact
|
||||
`WorldSession` lifetime:
|
||||
|
||||
- endpoint resolution and session construction;
|
||||
- event routing before the first network receive;
|
||||
- Connect → CharacterList validation → first-character selection → EnterWorld;
|
||||
- active command-bus publication;
|
||||
- per-frame Tick;
|
||||
- graceful stop, replacement/reconnect, and disposal;
|
||||
- session-generation and reentrant-operation gates;
|
||||
- failure convergence back to a clean offline state.
|
||||
|
||||
`LiveSessionEventRouter` owns typed session subscriptions and exact
|
||||
unsubscription. It routes into focused entity/environment/chat/vital and Core
|
||||
state handlers but owns no entity map, renderer, UI model, or gameplay state.
|
||||
`LiveSessionResetPlan` owns the ordered, failure-isolated reset transaction but
|
||||
does not become a second state owner.
|
||||
|
||||
At slice exit, `GameWindow` retains one `_liveSessionController` field plus
|
||||
narrow composition/lifecycle callbacks. `_liveSession`, `_commandBus`,
|
||||
`_combatChatTranslator`, `TryStartLiveSession`, `ClearInboundEntityState`, and
|
||||
`WireLiveSessionEvents` are removed. Every outbound caller resolves the current
|
||||
borrowed session from the controller at call time; no feature caches it.
|
||||
|
||||
## 2. Retail and shipped oracles
|
||||
|
||||
The accepted transport behavior remains pinned to:
|
||||
|
||||
- [`2026-07-20-retail-graceful-logout-pseudocode.md`](../research/2026-07-20-retail-graceful-logout-pseudocode.md);
|
||||
- `Proto_UI::LogOffCharacter @ 0x00546A20`;
|
||||
- `CharacterSet::UnPack @ 0x004FE340`;
|
||||
- `gmCharacterManagementUI::EnterGame @ 0x004ED440`;
|
||||
- `CPlayerSystem::LogOnCharacter @ 0x0055F890`;
|
||||
- `CPlayerSystem::RequestLogOff @ 0x00562DD0`;
|
||||
- inbound login-message dispatch `@ 0x0055C963`;
|
||||
- `CPlayerSystem::ExecuteLogOff @ 0x0055D780`;
|
||||
- `ClientNet::ExitWorldDisconnect @ 0x00541E00`;
|
||||
- `ClientNet::LogOffServer @ 0x00543EF0`;
|
||||
- `SharedNet::SendOptionalHeader @ 0x00543160`;
|
||||
- ACE `CharacterHandler.CharacterLogOff` and
|
||||
`Session.SendFinalLogOffMessages`;
|
||||
- Holtburger's connect/PlayerCreate/LoginComplete flow as a protocol
|
||||
cross-check, not as the logout oracle where its shortcut differs from retail;
|
||||
- [`2026-07-20-automated-world-lifecycle-gate.md`](2026-07-20-automated-world-lifecycle-gate.md),
|
||||
whose accepted connected trace proves F653 confirmation, transport
|
||||
disconnect, endpoint release, and fresh-process reconnect.
|
||||
|
||||
The controller must preserve these fixed facts:
|
||||
|
||||
1. Complete routing is installed before `Connect`; Connect can synchronously
|
||||
publish initial server time before character selection.
|
||||
2. Retail's CharacterList contains separate active and deleted collections.
|
||||
EnterGame requires a selected GUID in the active collection whose
|
||||
`GetGreyedOutFor` result is not positive. acdream's unattended auto-entry is
|
||||
an explicit adaptation: select the first active, non-greyed character and
|
||||
reject an empty/all-greyed list. A future retained character-select screen
|
||||
is separate M4/UI work.
|
||||
3. `WorldSession.EnterWorld` retains its shipped request → ServerReady →
|
||||
CharacterEnterWorld order. F657 uses `CharacterList.AccountName`, the
|
||||
canonical server-returned spelling, rather than the startup username.
|
||||
4. Closing an in-world session sends the eight-byte `[0xF653, active GUID]`
|
||||
request, drains without App world callbacks until the server's exact
|
||||
four-byte opcode-only F653 confirmation, sends transport Disconnect, and
|
||||
only then releases the socket. Once ConnectRequest negotiation has supplied
|
||||
receiver id/iteration, character-select, entering-world, and failed
|
||||
post-negotiation sessions also send transport Disconnect before socket
|
||||
release. `WorldSession.Dispose` remains the one wire owner.
|
||||
5. The existing close-only adaptation in divergence row AD-44 remains: acdream
|
||||
exits instead of returning to a retained character-select connection.
|
||||
6. The registered fixed ConnectResponse delay (UN-6) and initial
|
||||
LoginComplete-readiness gap (TS-28) remain explicit Core.Net follow-ups.
|
||||
They are not copied into, disguised by, or claimed by the App controller.
|
||||
|
||||
No new automatic network retry or logout shortcut is introduced. Core.Net
|
||||
changes stay surgical: retail CharacterSet parsing/selection data, exact F653
|
||||
confirmation, and negotiated-state Disconnect symmetry. The accepted packet
|
||||
codec, handshake loops, confirmation wait, and receive-thread design are not
|
||||
rewritten.
|
||||
|
||||
## 3. Proven baseline defects closed by this slice
|
||||
|
||||
The pre-slice audit found real lifetime defects, not merely style debt:
|
||||
|
||||
- repeated startup can overwrite an active controller/session without stopping
|
||||
the exact prior lifetime;
|
||||
- no-character and failure paths can leave a `LiveCommandBus` whose delegates
|
||||
capture a disposed session;
|
||||
- the no-character path leaks `CombatChatTranslator` subscriptions;
|
||||
- anonymous event wiring cannot be explicitly detached;
|
||||
- `GameEventDispatcher.Unregister(type)` cannot safely distinguish an old
|
||||
registration from a replacement;
|
||||
- the reset boundary leaves player identity, Core character state, Turbine
|
||||
rooms/cookie, shortcuts/components, combat/item mana/social state, motion
|
||||
metadata, and player-mode references from the prior session;
|
||||
- one reset-stage failure can prevent later network/effect cleanup;
|
||||
- a synchronous handler can request stop/reconnect while `Tick`, Connect, or
|
||||
EnterWorld is still unwinding.
|
||||
|
||||
Each is fixed at its owner. No suppression flag, delay, retry loop, or stale-GUID
|
||||
filter is acceptable.
|
||||
|
||||
## 4. Architecture and interfaces
|
||||
|
||||
### 4.1 Controller state
|
||||
|
||||
`LiveSessionController` exposes:
|
||||
|
||||
```csharp
|
||||
LiveSessionStartResult Start(RuntimeOptions options, ILiveSessionLifecycleHost host);
|
||||
LiveSessionStartResult Reconnect(RuntimeOptions options, ILiveSessionLifecycleHost host);
|
||||
void Stop();
|
||||
void Tick();
|
||||
|
||||
WorldSession? CurrentSession { get; } // borrowed; never cache
|
||||
ICommandBus Commands { get; } // NullCommandBus outside active InWorld
|
||||
bool IsInWorld { get; }
|
||||
ulong SessionGeneration { get; }
|
||||
```
|
||||
|
||||
The controller uses an injected internal operations seam for deterministic
|
||||
tests (resolve, create, connect, select, enter, tick, dispose). Production
|
||||
operations call the real `WorldSession`; tests do not open UDP sockets. This is
|
||||
an App orchestration seam, not a duplicate protocol implementation.
|
||||
|
||||
Every start attempt and accepted session has a monotonically increasing
|
||||
generation. Lifecycle calls made synchronously from a host/event callback mark
|
||||
the current attempt non-accepting and defer stop/reconnect until the outer
|
||||
operation unwinds. The outer attempt rechecks its generation after every
|
||||
callback and blocking network phase, so it cannot publish an old session after
|
||||
reentrant replacement.
|
||||
|
||||
Replacement order is fixed:
|
||||
|
||||
1. disable outbound commands and mark the router non-accepting;
|
||||
2. detach exact subscriptions and session-scoped translators;
|
||||
3. gracefully dispose the exact old `WorldSession`;
|
||||
4. detach the borrowed session from the host;
|
||||
5. execute the complete reset plan to convergence;
|
||||
6. create and bind the new session transactionally;
|
||||
7. Connect, validate/select, EnterWorld;
|
||||
8. publish active commands only after success.
|
||||
|
||||
### 4.2 Lifecycle host
|
||||
|
||||
`ILiveSessionLifecycleHost` is a narrow composition boundary, not a context
|
||||
bag. It has focused lifecycle calls only:
|
||||
|
||||
- bind one exact session and return an owned `LiveSessionBinding`;
|
||||
- reset session-scoped App state;
|
||||
- report connecting/connected status;
|
||||
- apply the selected character identity;
|
||||
- apply post-EnterWorld UI/settings state;
|
||||
- detach the exact session.
|
||||
|
||||
Entity, environment, and effect packets continue through focused sink
|
||||
interfaces. Later Slice 4 replaces the GameWindow entity sink with the two
|
||||
planned live-entity integration controllers without changing the session
|
||||
controller.
|
||||
|
||||
### 4.3 Exact subscription ownership
|
||||
|
||||
`LiveSessionEventRouter` stores named delegates against one exact event source,
|
||||
sets `Accepting = false` before teardown, and unsubscribes in reverse order.
|
||||
Construction is transactional: a partially created router unwinds every prior
|
||||
registration.
|
||||
|
||||
`ObjectTableWiring.Wire`, `CombatStateWiring.Wire`, and
|
||||
`GameEventWiring.WireAll` return idempotent owned registrations. The GameEvent
|
||||
dispatcher gains registration tokens that restore/remove only when their exact
|
||||
handler is still current. Nested registration A → B is safe when A disposes
|
||||
first: disposing B skips the retired A node and restores the session-owned
|
||||
predecessor (not a dead callback).
|
||||
|
||||
The router groups dependencies by focused responsibility:
|
||||
|
||||
- live entity/physics/effect sink;
|
||||
- environment/time sink;
|
||||
- Core object/combat/spell/player/social state bridge;
|
||||
- chat/vital bridge;
|
||||
- command router.
|
||||
|
||||
There is no `GameWindowContext`, GUID table, renderer registry, or generic
|
||||
service locator.
|
||||
|
||||
### 4.4 Command lifetime
|
||||
|
||||
`LiveSessionCommandRouter` owns one `LiveCommandBus`, the server/chat routing,
|
||||
and the exact session captured by its handlers. The controller exposes this bus
|
||||
only while that session is the active in-world generation. Before stop or
|
||||
replacement it returns `NullCommandBus`, so a retained panel can never send via
|
||||
the displaced socket.
|
||||
|
||||
Turbine channel resolution and labels move with the chat command/event owner.
|
||||
`ClientCommandController` remains the retail command behavior owner; GameWindow
|
||||
supplies its existing focused bindings during composition.
|
||||
|
||||
### 4.5 Reset convergence
|
||||
|
||||
`LiveSessionResetPlan` is an ordered list of named owner-reset operations. It
|
||||
runs every stage even when one fails, aggregates errors, and forbids a new
|
||||
session from starting until all stages converge. The plan clears:
|
||||
|
||||
- mouse-look, player mode/cameras, auto-entry, and teleport/reveal transit;
|
||||
- session origin/counters and selected-character identity;
|
||||
- equipped/external-container projections;
|
||||
- object, spell, magic, combat, item-mana, local-player, friends, squelch,
|
||||
Turbine-chat, shortcut, and desired-component state;
|
||||
- interaction/selection and selection-presentation state;
|
||||
- liveness and canonical live entities (including GpuWorldState persistence);
|
||||
- remote teleport, pending F754/F755 effects, and deferred animation hooks.
|
||||
|
||||
`LocalPlayerState.Clear`, `TurbineChatState.Reset`, and `SquelchState.Clear`
|
||||
are added to their real state owners. Chat history is intentionally retained
|
||||
across an in-process reconnect, matching the existing UI behavior, but its
|
||||
local-player GUID resets to zero. Vitals GUID, diagnostic player GUID, run/jump
|
||||
skills, character options, motion table, movement truth/shadow, active toon,
|
||||
shortcuts, and desired components reset to pre-login values.
|
||||
|
||||
## 5. Implementation sequence
|
||||
|
||||
### Commit A0 — retail lifecycle conformance corrections
|
||||
|
||||
- Write one consolidated pseudocode note for `CharacterSet::UnPack`,
|
||||
`gmCharacterManagementUI::EnterGame`, `CPlayerSystem::LogOnCharacter`, and
|
||||
negotiated logout/disconnect ordering.
|
||||
- Parse active and deleted CharacterList collections with cursor/truncation
|
||||
tests and preserve status/account/slot/boolean fields.
|
||||
- Add the unattended first-active-non-greyed selector and canonical-account
|
||||
EnterWorld input.
|
||||
- Require an exact four-byte F653 confirmation and send transport Disconnect
|
||||
for every negotiated session state.
|
||||
- Keep UN-6, TS-28, and AD-44 explicitly registered and unchanged.
|
||||
|
||||
### Commit A — owned subscription primitives
|
||||
|
||||
- Add nested-safe owned registrations to `GameEventDispatcher`.
|
||||
- Make ObjectTable, CombatState, and GameEvent wiring return disposables.
|
||||
- Add exact teardown, nested replacement, reverse disposal, and idempotence
|
||||
tests in Core.Net.
|
||||
|
||||
### Commit B — session event and command routers
|
||||
|
||||
- Add focused live-session event source/sink contracts.
|
||||
- Route every current direct WorldSession event exactly once.
|
||||
- Move chat/Turbine/vital routing and the live command bus out of GameWindow.
|
||||
- Own `CombatChatTranslator` and all wiring registrations transactionally.
|
||||
- Test post-dispose silence, partial-construction unwind, nested events, and
|
||||
router A/B replacement.
|
||||
|
||||
### Commit C — convergent session reset
|
||||
|
||||
- Add missing reset APIs to Core state owners.
|
||||
- Add `LiveSessionResetPlan` with named, failure-isolated stages.
|
||||
- Compose the complete current session manifest and test mutated A → reset →
|
||||
clean B state, including one throwing stage.
|
||||
|
||||
### Commit D — complete lifecycle controller
|
||||
|
||||
- Move resolve/create/Connect/CharacterList/selection/EnterWorld/stop/reconnect
|
||||
into `LiveSessionController`.
|
||||
- Add generation and operation-depth gates.
|
||||
- Add typed start results for Disabled, MissingCredentials, NoCharacters,
|
||||
Connected, Deferred, and Failed.
|
||||
- Test ordering, duplicate/reentrant start/stop/reconnect, Connect and
|
||||
EnterWorld failures, tick generation, and exact-once cleanup.
|
||||
|
||||
### Commit E — GameWindow cutover
|
||||
|
||||
- Implement the narrow host and packet sink interfaces.
|
||||
- Replace all direct session field reads with borrowed controller resolution.
|
||||
- Replace both UI command-bus providers with `controller.Commands`.
|
||||
- Delete the three old lifecycle/wiring methods and the displaced fields.
|
||||
- Preserve OnLoad, frame Tick, and shutdown stage order.
|
||||
|
||||
### Commit F — review corrections and documentation
|
||||
|
||||
- Run three independent read-only reviews: retail conformance,
|
||||
architecture/lifetime, and adversarial tests.
|
||||
- Fix every confirmed issue and repeat review until clean.
|
||||
- Run focused App/Core.Net tests, Release build, and the full Release suite.
|
||||
- Run the existing connected login/command/chat/portal/graceful-close/
|
||||
fresh-process-reconnect gate when local ACE is available.
|
||||
- Update code structure, roadmap, milestones, issues/divergence if needed,
|
||||
durable session memory, `AGENTS.md`, and `CLAUDE.md`.
|
||||
|
||||
## 6. Automated acceptance matrix
|
||||
|
||||
### Controller
|
||||
|
||||
- Routing is complete before Connect's synchronous server-time callback.
|
||||
- First active non-greyed CharacterList entry, GUID, name, canonical account,
|
||||
and active-list index are exact; deleted/greyed entries are rejected.
|
||||
- Empty list, Connect failure, EnterWorld failure, and binding failure leave no
|
||||
active session/router/bus and clean exactly once.
|
||||
- Duplicate `Start` is idempotent and cannot disturb a healthy active scope;
|
||||
explicit `Reconnect` gracefully stops A before B state is reset/wired.
|
||||
- Stop/reconnect requested during bind, Connect, selected-character callback,
|
||||
EnterWorld, or Tick cannot resurrect the outer generation.
|
||||
- Tick reaches only the exact current session; stopped/disposed Tick is a no-op.
|
||||
- Stop and Dispose are idempotent.
|
||||
|
||||
### Router and command bus
|
||||
|
||||
- Every direct session event routes once while accepting.
|
||||
- Dispose is reverse-order, idempotent, and post-dispose events are ignored.
|
||||
- Partial construction unwinds prior registrations.
|
||||
- GameEvent A/B nested ownership restores the nearest live predecessor.
|
||||
- Old command delegates are unreachable before old-session disposal and cannot
|
||||
send after replacement.
|
||||
- Domain-handler exceptions follow an explicit policy without corrupting
|
||||
subscription ownership.
|
||||
|
||||
### Reset
|
||||
|
||||
- Every named stage is attempted once even when another throws.
|
||||
- Failure blocks construction of session B and reports all stage errors.
|
||||
- Player identity, Vitals/Chat identity, diagnostic GUID, selected-character
|
||||
metadata, Core state, social/chat-room state, projections, live runtime,
|
||||
pending effects, and hooks all return to pre-login values.
|
||||
- Chat history is retained but no old own-GUID classification remains.
|
||||
- Live runtime convergence removes the old persistent player classification.
|
||||
|
||||
### Integration
|
||||
|
||||
- `GameWindow` has no `_liveSession`, `_commandBus`, or
|
||||
`_combatChatTranslator` field.
|
||||
- `TryStartLiveSession`, `ClearInboundEntityState`, and
|
||||
`WireLiveSessionEvents` no longer exist.
|
||||
- No substantial session callback body remains in `GameWindow`.
|
||||
- WorldSession retains the 8-byte request → exact 4-byte confirmation →
|
||||
Disconnect order and extends Disconnect symmetry to every negotiated state.
|
||||
- Release build and full suite are green.
|
||||
|
||||
## 7. Connected gate
|
||||
|
||||
Run a normal capped Release client against local ACE and prove:
|
||||
|
||||
1. login selects the same first character and reveals a complete world;
|
||||
2. system chat, say/tell/channel/server commands, vitals, combat state, and
|
||||
shortcuts/components still update;
|
||||
3. one portal/recall completes with the same reveal lifecycle;
|
||||
4. native close logs the F653 request/confirmation and transport Disconnect;
|
||||
5. ACE releases the exact endpoint;
|
||||
6. a fresh process reconnects cleanly with no old character state, command
|
||||
route, entities, effects, or persistent GUIDs.
|
||||
|
||||
The gate is behavioral and resource-oriented. It does not create a new visual
|
||||
approval pause; final campaign visual acceptance remains after Slice 8.
|
||||
|
||||
## 8. Review policy
|
||||
|
||||
One primary agent performs edits. Read-only agents independently audit retail
|
||||
conformance, architecture/lifetime, and adversarial tests after each ownership
|
||||
commit. Confirmed findings are fixed at their root and re-reviewed. No reviewer
|
||||
edits the shared worktree, and no callback facade back into a substantial old
|
||||
GameWindow body counts as extraction completion.
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
# GameWindow Slice 4 — live-entity App integration
|
||||
|
||||
**Status:** Complete 2026-07-21.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 4.
|
||||
**Baseline:** `ed9dc366`; `GameWindow.cs` is 14,310 lines, 274 fields, and
|
||||
190 methods before this slice. Release baseline: 6,714 passed / 5 skipped.
|
||||
**Behavior rule:** Preserve the accepted live-object identity, packet FIFO,
|
||||
independent timestamp authorities, render/physics/effect lifetime, and connected
|
||||
R6 behavior while moving their App integration to focused owners. Correct only
|
||||
proven retail-conformance defects with an explicit oracle and test; do not hide
|
||||
them inside a mechanical extraction.
|
||||
|
||||
**Progress ledger:**
|
||||
|
||||
- [x] A — canonical seams: `LiveWorldOriginState`, single-bind runtime-component
|
||||
lifecycle bridge, extracted `RemoteMotion`, and canonical physics-host
|
||||
ownership. Landed in `d68c83d1`, `5882b308`, and `fcb66198`. The final
|
||||
exact-record host checkpoint passed three clean reviews plus 6,743 Release
|
||||
tests / 5 skipped on 2026-07-21.
|
||||
- [x] B — pure appearance/projection helpers and controller-level
|
||||
characterization tests. Landed in `69a2ca0c`. Exact appearance/collision
|
||||
replacement, default-pose resolution, local-shadow ownership, and recursive
|
||||
retained projection withdrawal passed three clean reviews plus 6,799 Release
|
||||
tests / 5 skipped on 2026-07-21.
|
||||
- [x] C — hydration create/materialize/landblock-recovery ownership and direct
|
||||
CreateObject/streaming cutover. Landed in `d10c5f2d`; 6,854 Release tests
|
||||
passed / 5 skipped after three clean retail, architecture, and adversarial
|
||||
re-reviews on 2026-07-21.
|
||||
- [x] D — ObjDesc/Parent/Pickup/ready/withdraw ownership and exact retained
|
||||
identity across leave-world/re-entry. Landed in `fe551496`; 6,877 Release
|
||||
tests passed / 5 skipped after three clean retail, architecture, and
|
||||
adversarial re-reviews on 2026-07-21.
|
||||
- [x] E — Delete/prune and retryable exact-incarnation teardown ownership.
|
||||
- [x] F — Position/Vector/State/Movement controller, same-generation CreateObject
|
||||
tail, independent authority gates, and direct inbound cutover.
|
||||
- [x] G — `GameWindow` cleanup, three-agent review cycle, full Release suite,
|
||||
connected gates, documentation, and durable memory closeout.
|
||||
|
||||
## 1. Outcome and non-goals
|
||||
|
||||
This slice creates two cooperating App owners:
|
||||
|
||||
- `LiveEntityHydrationController` owns CreateObject, ObjDesc, Parent, Pickup,
|
||||
Delete/prune, DAT-backed appearance hydration, first materialization,
|
||||
landblock rehydration, ready publication, projection withdrawal, and exact
|
||||
App-component teardown over `LiveEntityRuntime`.
|
||||
- `LiveEntityNetworkUpdateController` owns accepted Position, Vector, State,
|
||||
and Movement routing into the existing motion, physics, projectile,
|
||||
presentation, and teleport owners. It also consumes the same-generation
|
||||
CreateObject update tail without reconstructing the entity.
|
||||
|
||||
`LiveEntityRuntime` remains the only owner of server GUID ↔ local identity,
|
||||
incarnation, accepted snapshots, independent authority versions, spatial
|
||||
worksets, and teardown tombstones. Neither new controller may contain a GUID
|
||||
dictionary or cache an incarnation outside the runtime record it is currently
|
||||
validating.
|
||||
|
||||
This is not a new entity model, physics algorithm, renderer, packet decoder, or
|
||||
DAT reader. It does not retire the documented future-packet, visibility-PVS,
|
||||
or null-parent-queue divergences. It does not redesign the accepted update
|
||||
thread or `RetailInboundEventDispatcher` FIFO.
|
||||
|
||||
## 2. Retail and shipped oracles
|
||||
|
||||
The extraction is pinned to:
|
||||
|
||||
- `SmartBox::HandleCreateObject @ 0x00454C80`;
|
||||
- `ACCObjectMaint::CreateObject @ 0x00558870`;
|
||||
- `SmartBox::ProcessObjectNetBlobs @ 0x00454B20` and
|
||||
`QueueBlobForObject @ 0x00451B90`;
|
||||
- `SmartBox::HandleReceivedPosition @ 0x00453FD0`;
|
||||
- `SmartBox::DoPickupEvent @ 0x00452240` and
|
||||
`DoParentEvent @ 0x00452290`;
|
||||
- `SmartBox::HandleDeleteObject @ 0x00451EA0`;
|
||||
- `CPhysicsObj::set_description @ 0x00514F40`;
|
||||
- `CPhysicsObj::set_state @ 0x00514DD0`;
|
||||
- `CPhysicsObj::change_cell @ 0x00513390`;
|
||||
- `CPhysicsObj::exit_world @ 0x00514E60` and
|
||||
`leave_world @ 0x005155A0`;
|
||||
- [`2026-07-13-retail-projectile-vfx-pseudocode.md`](../research/2026-07-13-retail-projectile-vfx-pseudocode.md);
|
||||
- [`2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md`](../research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md);
|
||||
- [`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md);
|
||||
- ACE and Holtburger object-update handlers as interpretation and protocol
|
||||
cross-checks, never as replacements for the named-retail ordering.
|
||||
|
||||
The following rules are load-bearing:
|
||||
|
||||
1. One logical object exists per accepted `INSTANCE_TS`. Equal generation
|
||||
mutates it; newer generation tears down and replaces it; older generation
|
||||
is ignored using retail's wrap-safe comparison, including the `0x8000`
|
||||
boundary.
|
||||
2. A fresh CreateObject constructs, describes, attaches, enters world, then
|
||||
replays queued blobs. An equal-generation CreateObject applies ObjDesc,
|
||||
Parent or Position/Pickup, Movement, State, Vector, then WeenieDesc without
|
||||
recreating the object.
|
||||
3. Position, Movement, State, Vector, ObjDesc, Teleport, ForcePosition,
|
||||
ServerControlledMove, and Instance authorities remain independent. After
|
||||
every reentrant callback, the exact record and relevant authority version
|
||||
are revalidated.
|
||||
4. Movement consumes strict `MOVEMENT_TS` before it can discover a stale
|
||||
server-controlled timestamp; equality on the latter is accepted.
|
||||
5. Parent, Pickup, and Position share `POSITION_TS`. Pickup unparents and
|
||||
leaves world; a later fresh Position unparents and restores the same object
|
||||
identity/resources.
|
||||
6. `leave_world` is not deletion. It clears cell/shadow/contact/transient
|
||||
presentation while retaining PartArray, movement, animation, scripts,
|
||||
effects, timestamps, and identity. Logical destruction alone performs
|
||||
`exit_world` manager cleanup.
|
||||
7. Inbound processing is one FIFO. Nested heterogeneous packets wait until the
|
||||
active packet reaches its complete tail.
|
||||
|
||||
## 3. Architecture and interfaces
|
||||
|
||||
### 3.1 Shared origin state
|
||||
|
||||
`LiveWorldOriginState` owns the session-scoped world center currently spread
|
||||
across `_liveCenterX`, `_liveCenterY`, and `_liveCenterKnown`:
|
||||
|
||||
```csharp
|
||||
bool IsKnown { get; }
|
||||
double CenterX { get; }
|
||||
double CenterY { get; }
|
||||
bool TryInitialize(double worldX, double worldY);
|
||||
void Recenter(double worldX, double worldY);
|
||||
void Reset();
|
||||
```
|
||||
|
||||
Hydration, accepted Position/teleport, streaming, and render consumers share
|
||||
this state directly. No GameWindow callback conceals origin mutation.
|
||||
|
||||
### 3.2 Runtime component lifecycle
|
||||
|
||||
Introduce:
|
||||
|
||||
```csharp
|
||||
interface ILiveEntityRuntimeComponentLifecycle
|
||||
{
|
||||
void TearDown(LiveEntityRecord exactRecord);
|
||||
}
|
||||
```
|
||||
|
||||
`DeferredLiveEntityRuntimeComponentLifecycle` is a composition-only,
|
||||
single-assignment bridge. Construction order is fixed:
|
||||
|
||||
1. construct the unbound bridge;
|
||||
2. construct `LiveEntityRuntime` with the bridge;
|
||||
3. construct effect, projectile, presentation, child, and related owners;
|
||||
4. construct hydration;
|
||||
5. bind the bridge exactly once to hydration;
|
||||
6. construct and bind the network-update sink;
|
||||
7. only then permit a live session to start.
|
||||
|
||||
Calling before bind fails fast; second bind is rejected. This removes the
|
||||
circular GameWindow teardown façade without making runtime mutation optional.
|
||||
The existing `CompositeLiveEntityResourceLifecycle` remains the sole logical
|
||||
mesh/script registration transaction; hydration does not become a second
|
||||
resource owner.
|
||||
|
||||
### 3.3 Canonical runtime components
|
||||
|
||||
Move `RemoteMotion` out of `GameWindow` into `AcDream.App.Physics` without
|
||||
semantic changes. Store physics-host resolution on the exact
|
||||
`LiveEntityRecord` (or an explicit runtime-owned component index) rather than
|
||||
the duplicate `_physicsHosts` GUID dictionary. Local-player host publication
|
||||
uses the same focused owner, not a second identity table. Preserve the full-cell
|
||||
binding and remote-placement contract.
|
||||
|
||||
### 3.4 Hydration API
|
||||
|
||||
The controller surface is narrow and event-shaped:
|
||||
|
||||
```csharp
|
||||
void OnCreate(WorldSession.EntitySpawn spawn);
|
||||
void OnObjDesc(ObjDescEvent.Parsed update);
|
||||
void OnDelete(DeleteObject.Parsed update);
|
||||
void OnPrune(LiveEntityPruneCandidate candidate);
|
||||
void OnPickup(PickupEvent.Parsed update);
|
||||
void OnParent(ParentEvent.Parsed update);
|
||||
bool TryAcceptParentForRender(ParentEvent.Parsed update);
|
||||
void OnLandblockLoaded(uint landblockId);
|
||||
bool EnsureWorldOriginAndRecover(
|
||||
LiveEntityRecord expected,
|
||||
ulong positionAuthorityVersion,
|
||||
WorldSession.EntitySpawn acceptedSpawn);
|
||||
void OnEntityReady(uint guid);
|
||||
bool WithdrawWorldProjection(uint guid);
|
||||
```
|
||||
|
||||
Hydration receives focused concrete collaborators/ports: runtime and object
|
||||
table, the sole `DatCollection` and shared DAT lock, physics data cache,
|
||||
spawn/animation/collision builders, spatial state/origin/streaming, and the
|
||||
existing effect/projectile/child/presentation/light owners. It must not receive
|
||||
a generic GameWindow context or a bundle of callbacks to old substantial
|
||||
methods.
|
||||
|
||||
ObjDesc mutates the captured exact entity. Acquire-before-publish rollback and
|
||||
post-callback incarnation checks preserve local ID, body, motion, physics host,
|
||||
effect owner, and parent state. Parent-on-spawn may reenter; the canonical
|
||||
snapshot is reread afterward. A materialized object only rebuckets; a pending,
|
||||
never-materialized object hydrates once. Landblock load never replays
|
||||
create-time logical resource activation.
|
||||
|
||||
### 3.5 Network-update API
|
||||
|
||||
The network owner accepts Position, Vector, State, Movement, and the equal-
|
||||
generation CreateObject tail. It resolves through `LiveEntityRuntime`, captures
|
||||
the relevant authority version, calls existing focused runtime owners, and
|
||||
revalidates exact record plus authority after every callback.
|
||||
|
||||
Hydration calls a once-bound `ILiveEntityNetworkUpdateSink` for the
|
||||
same-generation tail. Network position recovery calls
|
||||
`EnsureWorldOriginAndRecover`; no substantial dispatch returns to GameWindow.
|
||||
Local ForcePosition, teleport presentation, projectile corrections, shadow
|
||||
updates, movement animation, and static/headless branches retain their shipped
|
||||
ordering.
|
||||
|
||||
### 3.6 Direct composition
|
||||
|
||||
At slice exit these callbacks point directly to the new owners:
|
||||
|
||||
- session Create/Delete/Pickup/Parent/ObjDesc/Position/Vector/State/Movement;
|
||||
- streaming landblock-loaded rehydration;
|
||||
- liveness prune;
|
||||
- equipped-child parent acceptance and entity-ready replay;
|
||||
- runtime exact-component teardown.
|
||||
|
||||
`GameWindow` keeps controller construction plus narrow frame calls. The old
|
||||
handler, hydration, collision-registration, appearance-rebind, ready,
|
||||
leave-world, and teardown method bodies must be absent, not retained as
|
||||
one-line façades.
|
||||
|
||||
## 4. Commit sequence and gates
|
||||
|
||||
### A — canonical seams
|
||||
|
||||
- Add and test `LiveWorldOriginState`.
|
||||
- Add and test the fail-fast, single-bind lifecycle bridge.
|
||||
- Extract `RemoteMotion` mechanically; retarget all 51 App-test references.
|
||||
- Canonicalize physics-host ownership and remove `_physicsHosts` only after
|
||||
resolution/teardown/local-player tests prove equivalence.
|
||||
- Run App remote-motion, runtime, physics-host, session-reset, and structural
|
||||
suites; full Release build/test; three-agent review and re-review.
|
||||
|
||||
### B — presentation helpers and characterization
|
||||
|
||||
- Extract `AppearanceUpdateState`, appearance rebind, default-pose, and
|
||||
collision-build helpers behind focused App types.
|
||||
- Preserve the exact DAT lock and existing extracted WorldBuilder pipeline.
|
||||
- Add controller-harness fakes that require no Silk/GL context.
|
||||
- Characterize current leave-world failure ordering rather than silently
|
||||
redesigning it.
|
||||
- Run appearance, animation, spawn-adapter, collision, resource-lifecycle,
|
||||
and new characterization suites; full Release build/test; review cycle.
|
||||
|
||||
Completed in `69a2ca0c`. The checkpoint also pinned the retail `add_child` /
|
||||
`set_parent` / `leave_world` boundary required by C and D: parent timestamps
|
||||
are staged before validation, accepted relationships are committed one at a
|
||||
time, and world withdrawal recursively removes the exact parent-first
|
||||
projection subtree while retaining logical identity and retry ownership.
|
||||
Release build completed with zero warnings and errors; 6,799 tests passed with
|
||||
five intentional skips; retail, architecture, and adversarial re-reviews were
|
||||
clean.
|
||||
|
||||
### C — create and materialization
|
||||
|
||||
- Implement hydration CreateObject registration, first materialization,
|
||||
pending deferral, landblock recovery, and ready publication.
|
||||
- Preserve prior-incarnation cleanup failure aggregation and rollback.
|
||||
- Route session CreateObject and streaming callbacks directly.
|
||||
- Tests: loaded create order; pending→loaded; materialized rebucket; same/
|
||||
older/new/wrapped generations; nested callback replacement; registration
|
||||
failure/retry; no duplicate mesh/script/effect activation.
|
||||
- Full Release build/test; review cycle.
|
||||
|
||||
Completed in `d10c5f2d`. `LiveEntityHydrationController` now owns the exact
|
||||
CreateObject registration transaction, world-origin bootstrap, pending
|
||||
deferral, first DAT projection materialization, landblock recovery, and the
|
||||
ready/PES replay boundary. Same-generation CreateObject supersession carries
|
||||
a monotonic integration version and a persistent retry obligation, so an
|
||||
older reentrant materialization cannot publish or erase the newer canonical
|
||||
snapshot. Parent and pickup creates complete through their retail cell-less
|
||||
paths, while interrupted initial hydration reuses the same animation
|
||||
sequencer and completed owners retain their exact phase and callback queues.
|
||||
Session CreateObject and streaming callbacks route directly to the extracted
|
||||
owner; the old GameWindow bodies are gone. Release build completed with zero
|
||||
warnings and errors; 6,854 tests passed with five intentional skips; retail,
|
||||
architecture, and adversarial re-reviews were clean.
|
||||
|
||||
### D — appearance, parenting, pickup, withdrawal
|
||||
|
||||
- Move ObjDesc, Parent, Pickup, child-ready, collision registration, and
|
||||
projection withdrawal.
|
||||
- Tests: identity/component preservation; acquire failure rollback; stale or
|
||||
reentrant ObjDesc; parent-before-create canonical reread; Pickup/Parent
|
||||
leave-world; Position re-entry with unchanged local ID/resources.
|
||||
- Bind child callbacks directly and remove their GameWindow façades.
|
||||
- Full Release build/test; review cycle.
|
||||
|
||||
Completed in `fe551496`. `LiveEntityHydrationController` now owns accepted
|
||||
ObjDesc, Parent, Pickup, child-ready, and exact leave-world/re-entry routing.
|
||||
ObjDesc carries an independent authority/retry transaction and reports success
|
||||
only when the current visual projection is published; delayed landblock or
|
||||
Position recovery therefore refreshes the player paperdoll exactly once.
|
||||
Attached objects re-realize through the equipped-child owner without requiring
|
||||
a world Position, retain their local identity/resources, and restore their
|
||||
complete descendant chain synchronously. Ready publication pins the exact
|
||||
create, Position, ObjDesc, projection-mutation, projection-kind, spatial, and
|
||||
entity authorities across every callback. The retail absent AnimationFrame
|
||||
default is placement zero. Release build completed with zero warnings and
|
||||
errors; 6,877 tests passed with five intentional skips; retail, architecture,
|
||||
and adversarial re-reviews were clean.
|
||||
|
||||
### E — delete and exact teardown
|
||||
|
||||
- Move Delete/prune, teardown-plan construction, leave-world component cleanup,
|
||||
and retry tombstone behavior.
|
||||
- Bind runtime bridge and liveness directly.
|
||||
- Tests: unknown/stale/current Delete; callback deletion; dual failure;
|
||||
teardown retry; same-GUID newer generation during old teardown; reset
|
||||
convergence; no surviving renderer/collision/script/effect/light owner.
|
||||
- Full Release build/test; review cycle.
|
||||
|
||||
Completed in `f38822c4`. `LiveEntityRuntimeTeardownController` now owns the
|
||||
retryable App-component half of retail `CObjectMaint::DeleteObject` and keeps
|
||||
the stable cleanup plan on the exact record tombstone. Hydration rejects the
|
||||
local player before every lookup or side effect, gates remote Delete by the
|
||||
exact Instance timestamp, discards only unknown-owner pending effects, and
|
||||
routes the 25-second prune through the same transaction. Successful cleanup
|
||||
steps never replay after a failure; a same-GUID replacement cannot be mutated
|
||||
by the old incarnation's delayed cleanup. The derived remote stop-observation
|
||||
state also has a focused session owner instead of a loose GameWindow
|
||||
dictionary. Full Release validation passed 6,891 tests with five intentional
|
||||
skips and no errors; all three corrected-diff re-reviews were clean.
|
||||
|
||||
### F — network-update owner
|
||||
|
||||
- Move Movement, Vector, State, and Position routing plus the equal-generation
|
||||
CreateObject tail.
|
||||
- Preserve FIFO and independent authorities; test stale, equal, wrap, nested,
|
||||
and callback-invalidated updates for every channel.
|
||||
- Preserve ForcePosition, server-controlled move, teleport, projectile,
|
||||
shadow, static, airborne, autonomous, animated, and headless branches.
|
||||
- Route session callbacks directly and remove old GameWindow handlers.
|
||||
- Full Release build/test; review cycle.
|
||||
|
||||
Completed in `aa90c646`. `LiveEntityNetworkUpdateController` now owns accepted
|
||||
Position, Vector, State, and Movement routing;
|
||||
`LiveEntitySameGenerationUpdateRouter` owns the retail equal-generation
|
||||
CreateObject tail. `LiveEntityInboundAuthorityGate` pins the exact record,
|
||||
independent channel authority, and velocity authority before any reentrant App
|
||||
callback. `LiveEntityMotionRuntimeController` owns the shared physics-host,
|
||||
Setup, MoveTo/Sticky, Hidden-target, and server-controlled-cycle policy behind
|
||||
a once-bound construction bridge. ForcePosition preserves retail's
|
||||
blip-then-immediate-F7B1 order and stamps the exact Position snapshot serialized
|
||||
on the wire. Focused tests cover stale/equal/wrapped timestamps, callback
|
||||
replacement, GUID reuse, projectile/body/remote Vector precedence, mixed
|
||||
same-generation routing, bridge binding, and the exact 56-byte autonomous
|
||||
position payload. All three corrected-diff re-reviews were clean.
|
||||
|
||||
### G — closeout
|
||||
|
||||
- Structural tests prove both controllers lack identity dictionaries and
|
||||
`GameWindow` lacks every moved field/type/method/body.
|
||||
- Run all live-runtime, object-table, parent/attachment, appearance/animation,
|
||||
effect/light/projectile, remote-motion/physics, inbound FIFO, liveness,
|
||||
stress, session-reset, and router suites.
|
||||
- Run `dotnet build AcDream.slnx -c Release` and full Release tests.
|
||||
- Run the connected R6 route and graceful reconnect gate, then user visual
|
||||
checks for inventory/equip/parenting, death/corpse, and portals when
|
||||
available.
|
||||
- Update architecture, roadmap, milestones, issues/divergence register, this
|
||||
ledger, `AGENTS.md`/`CLAUDE.md` when current-state pointers change, and
|
||||
durable GameWindow/live-runtime memory.
|
||||
|
||||
Completed 2026-07-21. `GameWindow` fell from the Slice-3 baseline of 14,310
|
||||
lines to 10,301 lines; its top-level field and method declarations are now 267
|
||||
and 163. Release build completed with zero warnings/errors and the full suite
|
||||
passed 6,940 tests with five intentional skips. The 310-second connected R6
|
||||
route passed login, Caul/Sawato/Rynthid/Aerlinthe/Sawato-revisit/Holtburg/
|
||||
Caul-return materialization, turn sampling, three locomotion/jump/combat
|
||||
exercises, and graceful close. Live-server population churn, previously known
|
||||
missing DAT VFX diagnostics, and empty world-edge landblocks remained warnings,
|
||||
not lifecycle failures. Slice 5 is the next structural owner boundary.
|
||||
|
||||
## 5. Adversarial acceptance matrix
|
||||
|
||||
Automated acceptance includes:
|
||||
|
||||
- equal/old/new/wrapped `INSTANCE_TS`, including `0x8000`;
|
||||
- full same-generation call trace and WeenieDesc tail ordering;
|
||||
- nested heterogeneous FIFO and exact authority invalidation;
|
||||
- fresh loaded create and pending→loaded without replay;
|
||||
- loaded→loaded rebucket and loaded↔pending oscillation;
|
||||
- ObjDesc identity/body/motion/host/effect/parent preservation;
|
||||
- Parent/Pickup→cell-less→Position re-entry with unchanged local ID;
|
||||
- unknown/stale/current Delete, callback delete, GUID reuse, and retry;
|
||||
- state side-effect order and movement/ForcePosition timestamp order;
|
||||
- projectile and remote-position corrections across landblocks;
|
||||
- bridge unbound/second-bind failures;
|
||||
- session reset with zero surviving live owners;
|
||||
- 96-owner and repeated-recall stress with no resource growth;
|
||||
- source/reflection ownership gates.
|
||||
|
||||
Connected acceptance includes:
|
||||
|
||||
- capped login and populated radar/world;
|
||||
- remote creature motion and target/status projection;
|
||||
- wield/dewield, bow/wand/melee switching, ammo parenting, inventory pickup;
|
||||
- open and loot a corpse after approach;
|
||||
- kill/death/corpse lifetime;
|
||||
- recall and portal travel across landblocks, revisit, graceful close, and
|
||||
fresh-process reconnect;
|
||||
- uncapped post-reconnect performance checkpoint.
|
||||
|
||||
## 6. Review discipline
|
||||
|
||||
Every ownership commit receives three independent read-only reviews:
|
||||
|
||||
1. retail conformance — symbols, ordering, timestamps, and documented
|
||||
divergences;
|
||||
2. architecture/integration — identity ownership, dependency direction,
|
||||
update-thread rules, lifecycle symmetry, and GameWindow reduction;
|
||||
3. adversarial testing — malformed/stale packets, callback reentrancy,
|
||||
registration failure, GUID reuse, landblock churn, reset, and leaks.
|
||||
|
||||
The primary agent reproduces and fixes confirmed findings, reruns focused and
|
||||
full gates, and requests re-review until no actionable finding remains. No
|
||||
subagent writes the implementation.
|
||||
|
||||
## 7. Explicit carried divergences
|
||||
|
||||
Slice 4 preserves, rather than disguises:
|
||||
|
||||
- AD-32 future-generation non-effect packet dropping;
|
||||
- AP-65 picked-up data-only weenie retention;
|
||||
- AP-69 approximate 384-unit visibility envelope;
|
||||
- TS-32 missing complete child-before-parent null-object queue;
|
||||
- the equal-generation PES-table refresh and VectorUpdate airborne/state
|
||||
adaptations recorded during Slice 4 planning.
|
||||
|
||||
These remain visible in the divergence register and are not evidence that the
|
||||
ownership extraction itself is incomplete.
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
# GameWindow Slice 5 — landblock presentation
|
||||
|
||||
**Status:** Complete 2026-07-21 (`4a205a3e`).
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 5.
|
||||
**Baseline:** `2b10e91e`; `GameWindow.cs` is 10,301 lines, 267 fields, and
|
||||
163 methods. Release baseline: 6,940 passed / 5 skipped. The connected R6
|
||||
baseline passed seven destinations/revisits, three movement/jump/combat
|
||||
exercises, and graceful close in 310 seconds.
|
||||
**Behavior rule:** Preserve the accepted two-tier streaming quality, immutable
|
||||
per-job EnvCell transaction, render-readiness barrier, collision/light/script/
|
||||
plugin lifetime, and retryable detach-first retirement. This is an ownership
|
||||
move. Any proven defect needed to make that boundary safe gets its own oracle,
|
||||
test, and bisectable commit rather than being hidden in a mechanical move.
|
||||
|
||||
## Progress ledger
|
||||
|
||||
- [x] A — freeze production order and introduce explicit build/publication
|
||||
seams plus structural guards.
|
||||
- A1 landed in `4f965d06`: accepted results now enter one staged
|
||||
`LandblockPresentationPipeline` transaction; `GpuWorldState` returns an
|
||||
exact spatial-publication receipt before retryable render-pin/script
|
||||
activation; retries preserve FIFO suffixes, pending-live merges, and
|
||||
promotion identity across policy/generation changes. Three corrected-diff
|
||||
reviews are clean. Release gate: 6,955 passed / 5 skipped.
|
||||
- A2 landed in `090b0354`: every production load now captures a specified
|
||||
immutable origin (including valid map origin `(0,0)`) and carries it through
|
||||
worker construction, priority/supersession, completion, Far normalization,
|
||||
and publication. Origin-less or mismatched request-aware work fails before
|
||||
it can publish. Three corrected-diff reviews are clean. Release gate: 6,965
|
||||
passed / 5 skipped.
|
||||
- [x] B — capture one immutable world-origin snapshot per load and extract the
|
||||
serialized DAT-backed `LandblockBuildFactory`.
|
||||
- Landed in `5af101b2`: `LandblockBuildFactory` now owns the complete
|
||||
serialized worker-side DAT transaction, immutable height-table snapshot,
|
||||
stab/scenery/interior hydration, complete EnvCell build, and physics-DAT
|
||||
bundle. `GameWindow` only constructs the owner and passes its `Build`
|
||||
operation to the streamer; it no longer contains any build helper body.
|
||||
Hermetic tests pin Far/Near/Promote shape, zero-submesh visibility cells,
|
||||
missing-record behavior, the exact supplied DAT gate, non-interleaved full
|
||||
Near transactions, exception recovery, and immutable inputs. The installed
|
||||
Holtburg gate independently derives visibility completeness from
|
||||
`LandBlockInfo.NumCells`. Three corrected-diff reviews are clean. Release
|
||||
gate: 6,979 passed / 5 skipped; `GameWindow.cs` is 9,573 lines.
|
||||
- [x] C — extract terrain, visibility, EnvCell, building, and render-pinning
|
||||
publication.
|
||||
- Landed in `1029f837`: `LandblockRenderPublisher` owns captured-origin
|
||||
terrain publication, complete visibility-cell transactions, AABBs,
|
||||
building registries, EnvCell commit/preparation, and matching retirement
|
||||
operations. Invalid EnvCell identities fail before any presentation
|
||||
mutation; visibility-only cells and zero-submesh transactions remain
|
||||
first-class. Tests pin building-before-EnvCell order, duplicate and failed
|
||||
completion behavior, foreign receipts, post-pin preparation, real
|
||||
publish-to-retire balance, and hidden AABB ownership. Three corrected-diff
|
||||
reviews are clean. Release gate: 6,988 passed / 5 skipped;
|
||||
`GameWindow.cs` is 9,490 lines.
|
||||
- [x] D — extract physics/cache/collision publication and cross-landblock
|
||||
reflood ownership.
|
||||
- Landed in `3613d393`: `LandblockPhysicsPublisher` now owns captured-origin
|
||||
terrain/cell/portal/building physics, cached GfxObj shapes, mutually
|
||||
exclusive multipart BSP or Setup fallback collision, exact static-owner
|
||||
replacement, final reflood, demotion, and full removal. Every Setup shape
|
||||
shares the retail logical entity ID; building shells remain exclusive to
|
||||
the per-landcell building channel. Reapply retires stale cell/building/UCG
|
||||
state and omitted static owners by landblock prefix, rebinds `CurrCell` to
|
||||
the replacement object, and preserves adjacent owners even when collision
|
||||
footprints cross the seam. Three corrected-diff reviews are clean. Release
|
||||
gate: 7,008 passed / 5 skipped; zero-warning build; `GameWindow.cs` is
|
||||
9,043 lines.
|
||||
- [x] E — extract static lighting, translucency, default-script, and plugin
|
||||
presentation; compose the small `LandblockPresentationPipeline`.
|
||||
- Landed in `ea0da8c8`: render, physics, and DAT-static publishers now
|
||||
preflight immutable receipts before mutation and retain exact per-stage
|
||||
progress through retry. `LandblockStaticPresentationPublisher` owns static
|
||||
lights, translucency lifetime, and plugin projection; `GpuWorldState` and
|
||||
`EntityScriptActivator` explicitly rebind same-ID static snapshots without
|
||||
replaying Setup defaults or resetting animation/light/translucency state,
|
||||
while omitted IDs take full teardown. Source changes and duplicate IDs fail
|
||||
before presentation mutation, missing DefaultAnimation never claims an
|
||||
owner, and fallible reconciliation precedes pending-map consumption. Three
|
||||
corrected-diff reviews are clean. Release gate: 7,026 passed / 5 skipped;
|
||||
`GameWindow.cs` is temporarily 9,073 lines because production wiring lands
|
||||
before checkpoint G deletes the old callback body.
|
||||
- [x] F — move retryable demotion/full-retirement presentation under the
|
||||
pipeline without changing `LandblockRetirementCoordinator`'s ledger.
|
||||
- Landed in `ea7ffbc1`: the pipeline now owns a focused
|
||||
`LandblockPresentationRetirementOwner` and its exact detach-first ledger;
|
||||
`GameWindow` no longer owns a coordinator or retirement callback body.
|
||||
Full and Near retirements preserve their distinct terrain/physics/live
|
||||
owner matrices, successful stages and entity cursors never replay, and
|
||||
reentrant Begin/Advance requests serialize without mutating an active
|
||||
ticket enumeration. A committed visibility-observer failure retains the
|
||||
detach receipt, completes presentation teardown, drains deferred work,
|
||||
then rethrows. Constructor identity guards enforce one world-state,
|
||||
publisher, lighting, and translucency graph, while explicit concrete and
|
||||
legacy controller overloads make ignored presentation callbacks
|
||||
impossible. Three corrected-diff reviews are clean. Release gate: 7,039
|
||||
passed / 5 skipped; build green with 17 pre-existing test-project warnings;
|
||||
`GameWindow.cs` is 9,011 lines.
|
||||
- [x] G — cut `StreamingController` and `GameWindow` directly to the pipeline;
|
||||
delete old bodies, scratch state, and callback facades.
|
||||
- [x] H — three-agent corrected-diff review, full Release suite, deterministic
|
||||
lifecycle gate, nine-destination resource soak, documentation, and durable
|
||||
memory closeout.
|
||||
- Landed in `4a205a3e`: native shutdown now keeps the live-session reset
|
||||
dependencies alive until deferred/reentrant disposal actually converges;
|
||||
source and behavioral tests pin that completion barrier. Both connected
|
||||
harnesses scan the final post-close logs for shutdown/disposed-resource
|
||||
failures. The resource gate uses an already-warm Caul → Sawato → Caul
|
||||
plateau instead of comparing a cold first load to a warmed cache, and
|
||||
every route command blocks on its exact portal-materialization count.
|
||||
- All three final reviews are clean. Release gate: 7,054 passed / 5 skipped.
|
||||
The capped/reconnect lifecycle gate passed in 311.4 seconds. The
|
||||
synchronized nine-stop soak passed in 394.1 seconds; Caul return → plateau
|
||||
changed working/private memory by only +56.4/+48.4 MiB and update p95 by
|
||||
0.8 → 0.7 ms. `GameWindow.cs` closes Slice 5 at 8,811 raw lines, 247
|
||||
fields, and 153 methods—1,490 lines below the Slice-5 baseline and 6,912
|
||||
lines (44%) below the campaign baseline.
|
||||
|
||||
Every checked checkpoint is committed as a bisectable architectural unit.
|
||||
Documentation records the exact commit and accepted test count as work lands.
|
||||
|
||||
## 1. Outcome and non-goals
|
||||
|
||||
At slice exit, `GameWindow` only composes and retains the landblock presentation
|
||||
owner. It no longer builds a landblock from DAT records, hydrates scenery or
|
||||
interiors, constructs a physics DAT bundle, publishes terrain/cells/collision/
|
||||
lights/plugins, or advances presentation retirement.
|
||||
|
||||
The final owner graph is deliberately not a replacement god object:
|
||||
|
||||
```text
|
||||
StreamingController (tier, region, generation, priority, budget)
|
||||
│ accepted Loaded / Promoted / Unloaded result
|
||||
▼
|
||||
LandblockPresentationPipeline (transaction order only)
|
||||
├── LandblockBuildFactory worker-only DAT transaction
|
||||
├── LandblockRenderPublisher terrain/cells/EnvCell/building/render pins
|
||||
├── LandblockPhysicsPublisher terrain/cell/portal/static collision
|
||||
├── LandblockStaticPresentation light/translucency/script/plugin projection
|
||||
└── LandblockRetirementCoordinator detach-first retry ledger
|
||||
│
|
||||
▼
|
||||
GpuWorldState (spatial buckets, pending live projections, tier, AABB)
|
||||
```
|
||||
|
||||
`StreamingController` remains the sole desired-tier and stale-generation
|
||||
authority. `GpuWorldState` remains the sole spatial-bucket and retained-live-
|
||||
projection owner. `LandblockStreamer` remains the sole worker queue. Existing
|
||||
renderers, physics/cache registries, lighting, scripts, plugin state, and
|
||||
`WorldRevealCoordinator` remain independent owners.
|
||||
|
||||
This slice does not change Near/Far radii, mesh or texture quality, world view
|
||||
distance, portal visibility, GPU frame fences, the draw graph, live-entity
|
||||
identity, or the update/render phase order. It does not re-port algorithms
|
||||
already inventoried under `Rendering/Wb` or introduce another DAT reader.
|
||||
|
||||
## 2. Retail, WorldBuilder, and shipped oracles
|
||||
|
||||
The extraction preserves the following retail order and already-shipped
|
||||
adaptations:
|
||||
|
||||
- `LScape::grab_visible_cells @ 0x00504EC0`: initialize buildings and gather
|
||||
visible cells before static/dynamic activation;
|
||||
- `CLandBlock::init_buildings @ 0x0052FD80`;
|
||||
- `CLandBlock::grab_visible_cells @ 0x0052F460`;
|
||||
- `CLandBlock::init_static_objs @ 0x00530A40` and
|
||||
`CLandBlock::init_dyn_objs @ 0x0052F3B0`;
|
||||
- `CLandBlock::destroy_static_objects @ 0x0052FA50`,
|
||||
`CLandBlock::Destroy @ 0x0052FAA0`, and
|
||||
`CLandBlock::release_all @ 0x0052FCF0` for static/building/cell teardown;
|
||||
- `CObjCell::init_objects @ 0x0052B420` →
|
||||
`CPhysicsObj::recalc_cross_cells @ 0x00515A30`; acdream's per-landblock
|
||||
`RefloodLandblock` remains the registered asynchronous adaptation;
|
||||
- `PView::InitCell @ 0x005A4B70` and
|
||||
`CEnvCell::find_visible_child_cell @ 0x0052DC50`;
|
||||
- `CBuildingObj::find_building_collisions @ 0x006B5300`;
|
||||
- `CPhysicsObj::add_shadows_to_cells @ 0x00514AE0`;
|
||||
- `SmartBox::UseTime @ 0x00455410`; acdream's shared reveal coordinator is the
|
||||
asynchronous equivalent of retail's synchronous cell readiness;
|
||||
- [`worldbuilder-inventory.md`](../architecture/worldbuilder-inventory.md),
|
||||
especially the one-job/one-complete-EnvCell-transaction and sole-DAT-reader
|
||||
rules;
|
||||
- the current render and two-tier streaming digests, including logical
|
||||
withdrawal before fence-delayed GPU reuse and the prohibition on reducing
|
||||
view distance as an optimization;
|
||||
- [`2026-07-13-envcell-landblock-transaction.md`](../research/2026-07-13-envcell-landblock-transaction.md)
|
||||
and
|
||||
[`2026-07-18-retail-texture-resource-lifetime-pseudocode.md`](../research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md).
|
||||
|
||||
The named retail decomp is the oracle for lifecycle ordering. The extracted
|
||||
WorldBuilder code is the oracle for already-ported terrain, scenery, EnvCell,
|
||||
mesh, and texture mechanics. ACE and the retained WorldBuilder reference tree
|
||||
are cross-checks, not new dependencies.
|
||||
|
||||
## 3. Load-bearing invariants
|
||||
|
||||
### 3.1 Worker build
|
||||
|
||||
1. One `Load` captures landblock ID, job kind, generation, and the accepted
|
||||
world-origin center on the update thread. The worker never reads the
|
||||
update-thread-confined `LiveWorldOriginState`.
|
||||
2. All DAT reads for one build execute under the existing single-reader gate.
|
||||
The worker performs CPU work only: no GL call, renderer/physics publication,
|
||||
registry mutation, or shared pending cell bag.
|
||||
3. Far reads the heightmap only and emits no static entities, EnvCells, or
|
||||
physics bundle. Near/Promote emits hydrated stabs, procedural scenery,
|
||||
interiors, a complete `PhysicsDatBundle`, and one immutable
|
||||
`EnvCellLandblockBuild`.
|
||||
4. Every valid EnvCell is present in the visibility transaction even when it
|
||||
has zero drawable shell submeshes. Geometry IDs retain their current
|
||||
deterministic arithmetic.
|
||||
5. Missing records keep the current skip/null semantics. A failed build has no
|
||||
externally visible partial publication.
|
||||
|
||||
### 3.2 Publication
|
||||
|
||||
For an accepted Near load or promotion, the transaction order remains:
|
||||
|
||||
1. publish terrain and rebase it to the captured origin;
|
||||
2. commit the complete cell-visibility snapshot and AABB;
|
||||
3. populate physics data and publish terrain/cells/portals/buildings;
|
||||
4. commit building registries and the EnvCell renderer transaction;
|
||||
5. publish static BSP/cylinder collision, lights, translucency, and DAT-static
|
||||
plugin snapshots; reflood the landblock;
|
||||
6. publish/merge the canonical spatial bucket in `GpuWorldState`, acquiring
|
||||
ordinary and synthetic EnvCell render IDs and activating static defaults;
|
||||
7. only after the pins exist, replay schema-aware EnvCell mesh preparation;
|
||||
8. only after spatial publication, restore retained live projections.
|
||||
|
||||
Render readiness means Near tier plus real uploaded ownership for every
|
||||
required ordinary and synthetic EnvCell render ID. Dictionary publication is
|
||||
not readiness. Duplicate Near completion is a no-op; stale Far cannot erase a
|
||||
Near layer. Promotion-before-base and the accepted Near-completion-while-Far-
|
||||
desired synthesis remain `StreamingController` policy.
|
||||
|
||||
### 3.3 Demotion and retirement
|
||||
|
||||
1. `GpuWorldState.DetachNearLayer` or `DetachLandblock` commits first. No
|
||||
presentation cleanup failure can resurrect spatial membership.
|
||||
2. Near→Far keeps terrain and all live projections, but removes DAT statics,
|
||||
static scripts/classification, lights/translucency/plugins, indoor physics,
|
||||
cell visibility, building registries, and EnvCells.
|
||||
3. Full retirement additionally releases terrain and full landblock physics.
|
||||
Persistent player projection is rescued and still-live server projections
|
||||
are parked for rebucketing with the same identity.
|
||||
4. The retryable order is exactly: mesh references → static scripts →
|
||||
classification → entity lights → entity translucency → DAT-static plugin
|
||||
projection → terrain for Full only → physics remove/demote → cell visibility
|
||||
→ building registry → EnvCell renderer.
|
||||
5. Successful stages and entity cursors never replay. Same-ID replacement is
|
||||
fenced until every required stage converges, including across controller or
|
||||
radius reconfiguration.
|
||||
6. GPU range/layer reuse remains fence-delayed by `GpuFrameFlightController`.
|
||||
|
||||
## 4. Architecture and interfaces
|
||||
|
||||
### 4.1 Immutable build request
|
||||
|
||||
Introduce a request equivalent to:
|
||||
|
||||
```csharp
|
||||
public readonly record struct LandblockBuildRequest(
|
||||
uint LandblockId,
|
||||
LandblockStreamJobKind Kind,
|
||||
ulong Generation,
|
||||
int OriginCenterX,
|
||||
int OriginCenterY);
|
||||
```
|
||||
|
||||
The generation remains for matching and diagnostics; the builder never uses it
|
||||
to decide residency. Results carry the same origin snapshot so apply cannot
|
||||
combine worker geometry from one center with render-thread placement from a
|
||||
later center.
|
||||
|
||||
### 4.2 DAT build factory
|
||||
|
||||
`LandblockBuildFactory` receives `IDatReaderWriter`, the one shared DAT gate,
|
||||
the immutable height table, and worker-safe mesh/physics-cache collaborators.
|
||||
Its public surface is one operation:
|
||||
|
||||
```csharp
|
||||
LandblockBuild? Build(in LandblockBuildRequest request);
|
||||
```
|
||||
|
||||
The extracted `BuildPhysicsDatBundle`, stab hydration, scenery generation,
|
||||
interior/EnvCell construction, and terrain sampling remain private focused
|
||||
helpers. No method accepts `GameWindow`, `LiveWorldOriginState`, GL objects, or
|
||||
presentation registries.
|
||||
|
||||
### 4.3 Focused publishers
|
||||
|
||||
`LandblockRenderPublisher` owns terrain slot publication, AABB, cell
|
||||
visibility, building registries, EnvCell commit, static render pin/preparation,
|
||||
and a typed immutable diagnostics snapshot.
|
||||
|
||||
`LandblockPhysicsPublisher` owns cache population, terrain/cell/portal/building
|
||||
physics, static multipart BSP and exclusive Setup-cylinder/sphere fallback,
|
||||
demotion/full removal, and final cross-cell reflood.
|
||||
|
||||
`LandblockStaticPresentationPublisher` owns idempotent static light and
|
||||
translucency replacement, static script activation/removal at the existing
|
||||
spatial registration boundary, and DAT-static plugin spawn/removal projection.
|
||||
|
||||
The pipeline calls concrete publishers in the fixed order. Publish/retire
|
||||
interfaces expose no DAT reader. Diagnostics move with the owner and the Debug
|
||||
UI reads a snapshot rather than `GameWindow` scratch counters.
|
||||
|
||||
### 4.4 Policy cutover
|
||||
|
||||
`StreamingController` keeps result validation and calls the pipeline only after
|
||||
generation/tier/duplicate/retirement-fence checks. It must not gain DAT, GL,
|
||||
physics, plugin, light, or live-GUID state. `GpuWorldState` keeps its buckets,
|
||||
pending Near/render-ID parking, and live rescue. Existing hidden presentation
|
||||
side effects may be made explicit only when tests prove identical ordering and
|
||||
balance.
|
||||
|
||||
`GameWindow` retains construction/wiring plus one pipeline field. Structural
|
||||
tests forbid these methods from returning:
|
||||
|
||||
```text
|
||||
BuildLandblockForStreaming
|
||||
BuildLandblockForStreamingLocked
|
||||
BuildPhysicsDatBundle
|
||||
BuildSceneryEntitiesForStreaming
|
||||
BuildInteriorEntitiesForStreaming
|
||||
EnsureEnvCellMeshesAfterPin
|
||||
ApplyLoadedTerrain
|
||||
ApplyLoadedTerrainLocked
|
||||
AdvanceLandblockPresentationRetirement
|
||||
```
|
||||
|
||||
## 5. Checkpoint execution
|
||||
|
||||
### A — characterization and seams
|
||||
|
||||
- Add owner-call trace tests around the current production order and payload
|
||||
multiplicity.
|
||||
- Introduce immutable build/publication request/result seams without changing
|
||||
the live call graph. The typed apply-diagnostics snapshot moves with its
|
||||
concrete render owner in C instead of creating a temporary A-stage owner.
|
||||
- Add dependency/reflection guards: no origin-state read from worker, no DAT in
|
||||
publish/retire interfaces, no residency/generation map in the pipeline.
|
||||
|
||||
### B — origin snapshot and build factory
|
||||
|
||||
- Capture origin with every enqueued load; preserve it through priority queue,
|
||||
completion, promotion, and Far synthesis.
|
||||
- Move the complete serialized DAT build verbatim into
|
||||
`LandblockBuildFactory`; keep `DatCollection` access behind
|
||||
`IDatReaderWriter` and the shared gate.
|
||||
- Test Far/Near payload shape, repeat-build determinism, EnvCell completeness,
|
||||
physics-DAT closure, missing records, lock serialization, and zero DAT reads
|
||||
during publication.
|
||||
|
||||
### C — render publication
|
||||
|
||||
- Extract terrain upload/rebase, CellVisibility, AABB, building registry,
|
||||
EnvCell renderer commit, render-ID pin/preparation, and diagnostics.
|
||||
- Test visibility-only cells, zero-submesh EnvCells, promotion-before-base,
|
||||
real render readiness, reapply idempotency, and release balance.
|
||||
|
||||
### D — physics publication
|
||||
|
||||
- Extract physics cache commit, terrain/cell/portal/building setup, static
|
||||
multipart BSP/cylinder registration, reflood, demote, and full removal.
|
||||
- Use two adjacent landblocks to prove one retirement cannot erase a neighbor's
|
||||
seam collision. Pin Far terrain-only, Near reapply, promotion, demotion, and
|
||||
full unload symmetry.
|
||||
|
||||
### E — static presentation and transaction coordinator
|
||||
|
||||
- Extract static lights/translucency/plugin projection and make existing static
|
||||
script ownership explicit without replaying defaults.
|
||||
- Compose the small `LandblockPresentationPipeline`; add exact order,
|
||||
multiplicity, rollback/blocked-publication, and owner-balance tests.
|
||||
|
||||
### F — retirement
|
||||
|
||||
- Move production retirement callbacks under the pipeline while keeping
|
||||
`LandblockRetirementCoordinator` as the exact retry ledger.
|
||||
- Inject failure before/after each boundary; prove only unfinished suffixes and
|
||||
exact entity cursors resume. Test Near and Full requirements separately.
|
||||
|
||||
### G — direct cutover and cleanup
|
||||
|
||||
- Route `LandblockStreamer` build and `StreamingController` apply/retire calls
|
||||
directly to the new owners.
|
||||
- Delete the old GameWindow bodies, callback facades, building-registry and
|
||||
apply-diagnostic scratch state that now have focused owners.
|
||||
- Add structural source tests and remeasure GameWindow lines/fields/methods.
|
||||
|
||||
**Complete in `c79d0a49`.** Production constructs one concrete pipeline;
|
||||
legacy callback constructors are internal test seams and structural tests keep
|
||||
them out of the composition root. The old GameWindow apply methods and their
|
||||
building/diagnostic scratch state are deleted. A retained
|
||||
`StreamingOriginRecenterCoordinator` now joins complete old-window
|
||||
presentation retirement to teleport recenter and session cancellation before
|
||||
the shared origin may be reused. Reviews closed full-window overlap, radius
|
||||
reconfiguration, sealed-dungeon bootstrap, committed-callback retry,
|
||||
reentrancy, fast-relogin, and ordinary-logout gaps. Three final reviews are
|
||||
clean; the zero-warning Release build and 7,052 tests pass with five skips.
|
||||
`GameWindow.cs` was 8,793 raw lines at checkpoint G, 1,508 below the Slice-5
|
||||
baseline and 6,930 (44%) below the campaign baseline.
|
||||
|
||||
### H — closeout
|
||||
|
||||
**Complete in `4a205a3e`.** H proved the landed graph under deterministic and
|
||||
connected lifecycle churn, corrected shutdown-stage ordering so session reset
|
||||
converges before its streaming dependencies are disposed, and strengthened the
|
||||
resource oracle to compare already-warm steady-state revisits.
|
||||
|
||||
- Run focused App/Core/Net tests after each checkpoint.
|
||||
- After each code checkpoint, run three independent read-only reviews: retail/
|
||||
WorldBuilder conformance, architecture/integration, and adversarial failure
|
||||
analysis. Fix confirmed findings and repeat review until clean.
|
||||
- Run `dotnet build AcDream.slnx -c Release` and the full Release test suite.
|
||||
- Run deterministic first-login/portal lifecycle automation and the connected
|
||||
nine-destination resource soak. Verify retirement/preparation queues drain,
|
||||
residence matches the region, resource counts remain bounded, and graceful
|
||||
close succeeds.
|
||||
- Update the divergence register pointers in IA-14, AD-2, AD-22, and any other
|
||||
row whose cited ownership moved. Keep AD-6, AD-24, AP-31, and TS-52 unless a
|
||||
separate retail mechanism actually lands.
|
||||
- Update architecture, milestones, roadmap, issues if needed, synchronized
|
||||
`AGENTS.md`/`CLAUDE.md`, and durable decomposition/render memory.
|
||||
|
||||
The final IA-14/AD-2/AD-22 audit found no new retail divergence. IA-14 and
|
||||
AD-22 still point at the correct WB/mesh owners; AD-2 now names the extracted
|
||||
origin-recenter/presentation owners. AD-6, AD-24, AP-31, and TS-52 remain open
|
||||
as required.
|
||||
|
||||
## 6. Acceptance matrix
|
||||
|
||||
Automated acceptance includes:
|
||||
|
||||
- Far heightmap-only and complete immutable Near/Promote payloads;
|
||||
- deterministic static/scenery/interior IDs, bounds, offsets, cells, and shell
|
||||
geometry IDs across repeated builds;
|
||||
- single-reader DAT gate and no publish-time DAT access;
|
||||
- exact owner-call sequence for load, promotion, Far synthesis, demotion, full
|
||||
unload, replacement, and failure retry;
|
||||
- stale/duplicate completions invoke no presentation participant;
|
||||
- complete render pins and real readiness before reveal;
|
||||
- static lights/translucency/scripts/plugins and mesh references remain
|
||||
balanced through load → reapply → demote → promote → full unload;
|
||||
- adjacent-landblock collision survives one neighbor's retirement;
|
||||
- first login with unknown origin starts no build, and stale placeholder/old-
|
||||
center completions cannot publish or satisfy reveal;
|
||||
- reflection/source gates keep moved methods and scratch ownership out of
|
||||
`GameWindow` and keep policy/state out of the pipeline.
|
||||
|
||||
Connected acceptance includes fresh login, outdoor travel, world-edge empty
|
||||
blocks, dungeon travel, same-location revisit, turn sampling at every stop,
|
||||
movement/jump/combat exercises, portal reveal, and graceful close. No view-
|
||||
distance or quality reduction is an acceptable way to pass the resource gate.
|
||||
|
||||
## 7. Known risks and explicit traps
|
||||
|
||||
- A worker must not read a later live origin than the job it is building.
|
||||
- A Near promotion may complete before its Far base; parking behavior and
|
||||
required render IDs must survive unchanged.
|
||||
- Duplicate Near/ForceReload must not stack lights, scripts, plugin events,
|
||||
meshes, collision, or EnvCells.
|
||||
- Demotion must never retire live entities or terrain.
|
||||
- Publication must not be mistaken for uploaded render readiness.
|
||||
- Failed retirement must survive controller/radius replacement.
|
||||
- Do not move `WbDrawDispatcher` or any draw-graph body; that is Slice 7.
|
||||
- Do not reintroduce worker-side `RegisterCell`, global pending renderer lists,
|
||||
legacy rendering, or another DAT reader.
|
||||
- Do not reduce Near/Far radii, texture detail, or mesh quality.
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
# GameWindow Slice 6 — update-frame orchestration
|
||||
|
||||
**Status:** Complete 2026-07-22.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 6.
|
||||
**Baseline:** `d4ecac1d`; `GameWindow.cs` is 8,811 raw lines, 247 fields, and
|
||||
153 methods. Release baseline: 7,054 passed / 5 skipped. The connected
|
||||
lifecycle gate and synchronized nine-stop resource soak both pass.
|
||||
**Behavior rule:** This is an ownership extraction. Preserve the accepted
|
||||
retail object/network order, asynchronous streaming-readiness adaptation,
|
||||
portal transit, input semantics, and camera presentation. A defect exposed by
|
||||
the boundary receives its own deterministic oracle and bisectable fix; it is
|
||||
never hidden behind a retry, delay, suppression flag, or reordered callback.
|
||||
|
||||
## Progress ledger
|
||||
|
||||
- [x] A — freeze the complete production phase graph and introduce the typed
|
||||
orchestration contract plus structural/order guards.
|
||||
- [x] B — extract the pre-network live-object presentation phase and the
|
||||
non-advancing post-network spatial reconciler; remove callbacks into
|
||||
`GameWindow` from `RetailLiveFrameCoordinator`.
|
||||
- [x] C — extract streaming-origin convergence, observer selection, dungeon
|
||||
collapse, streaming tick, and rescued-entity rebucketing.
|
||||
- [x] D — extract dispatcher/raw-mouse/combat input sampling without changing
|
||||
movement-command or UI-capture semantics.
|
||||
- [x] E — extract the complete local-player teleport owner and wire it between
|
||||
the existing post-network liveness and player-mode auto-entry phases.
|
||||
- [x] F — extract the shared player-mode lifecycle plus fly/chase/player
|
||||
camera presentation and cut production over to one
|
||||
`UpdateFrameOrchestrator`.
|
||||
- [x] G — delete the old `OnUpdate` bodies, callback facades, and obsolete
|
||||
frame state; run focused corrected-diff reviews after each ownership edge.
|
||||
- [x] H — full Release suite, connected lifecycle/reconnect gate, synchronized
|
||||
resource soak, documentation, durable memory, and line/field/method closeout.
|
||||
|
||||
Every checked checkpoint is committed as one bisectable architectural unit.
|
||||
Documentation records the exact commit and accepted test count as work lands.
|
||||
|
||||
Checkpoint A introduced the internal `AcDream.App.Update` contract, one typed
|
||||
`UpdateFrameClock`/`IPhysicsScriptTimeSource`, the twelve-phase outer-order
|
||||
oracle, exact teardown and invalid-delta tests, and guards against a transitive
|
||||
`GameWindow` clock capture. Production now publishes PES time once per update;
|
||||
18 focused Release tests and the full 2,709-test App project pass, and all
|
||||
three corrected-diff reviews are clean.
|
||||
|
||||
Checkpoint B moved the complete local/ordinary/static/effect body into
|
||||
`LiveObjectFrameController`, made `RetailLiveFrameCoordinator`'s
|
||||
object → inbound batch → command → spatial-reconcile barrier fully typed,
|
||||
and removed animation/runtime/projectile back-references to `GameWindow`.
|
||||
`GameWindow.cs` is now 8,716 raw lines. The corrected diff passes 239 focused
|
||||
Release tests, the 2,732-test App suite, and the full 7,090-test Release suite
|
||||
(5 fixture/environment skips); all three independent reviews are clean.
|
||||
|
||||
Checkpoint C moved retained origin convergence, readiness, observer selection,
|
||||
sealed-dungeon collapse, streaming publication, and persistent live-projection
|
||||
rescue into `StreamingFrameController`. Production keeps streaming publication
|
||||
ahead of input and the live object/network barrier, so a same-frame
|
||||
CreateObject can enter the newly resident bucket. The focused 146-test Release
|
||||
selection, 2,754-test App suite, and complete 7,112-test Release suite (5
|
||||
fixture/environment skips) pass. Login-before-position, portal hold, outdoor
|
||||
and sealed/SeenOutside EnvCells, pending recenter, exact rescue rebucketing,
|
||||
GUID deletion, and session reset are pinned; all three corrected-diff reviews
|
||||
are clean. `GameWindow.cs` is now 8,569 raw lines.
|
||||
|
||||
Checkpoint D moved semantic hold delivery, movement sampling, autorun,
|
||||
instant mouse-look, cursor capture, raw filtering/idle handling, and combat
|
||||
intent into `GameplayInputFrameController` and focused typed owners. Devtools
|
||||
keyboard capture pauses all gameplay movement without clearing the autorun
|
||||
latch; retained chat suppresses held keys while autorun continues. Every
|
||||
focus, teleport, camera/player-mode, reset, and shutdown boundary now releases
|
||||
mouse-look through one lifecycle path and clears stale RMB orbit state.
|
||||
Production combat requests, automatic hostile selection, UI-capture probes,
|
||||
and movement-truth diagnostics no longer retain callbacks into `GameWindow`.
|
||||
The 2,787-test App suite and complete 7,145-test Release suite pass (5
|
||||
fixture/environment skips); all three corrected-diff reviews are clean.
|
||||
`GameWindow.cs` is now 8,205 raw lines.
|
||||
|
||||
## 1. Outcome and non-goals
|
||||
|
||||
At slice exit, `GameWindow.OnUpdate` starts the profiler scope and delegates one
|
||||
host delta to `UpdateFrameOrchestrator`. `GameWindow` composes concrete owners;
|
||||
it no longer selects a streaming observer, advances animations/effects,
|
||||
samples raw mouse input, sequences inbound network traffic, advances portal
|
||||
transit, or updates gameplay cameras.
|
||||
|
||||
The owner graph is explicit rather than a list of anonymous callbacks:
|
||||
|
||||
```text
|
||||
UpdateFrameOrchestrator
|
||||
├── live teardown convergence retry retained tombstones
|
||||
├── UpdateFrameClock normalized host time / PES clock
|
||||
├── StreamingFrameController residence/readiness pre-pass
|
||||
├── GameplayInputFrameController semantic input + mouse/combat intent
|
||||
├── RetailLiveFrameCoordinator retail SmartBox object barrier
|
||||
│ ├── LiveObjectFrameController local/ordinary/static/effect tails
|
||||
│ ├── LiveSessionController inbound network drain
|
||||
│ ├── RetailLocalPlayerFrame CommandInterpreter position tail
|
||||
│ └── LiveSpatialReconciler non-advancing authoritative refresh
|
||||
├── LiveEntityLivenessController expiry after accepted inbound state
|
||||
├── LocalPlayerTeleportController local transit/reveal/tunnel state
|
||||
├── PlayerModeAutoEntry one-shot ready-world entry
|
||||
├── PlayerModeController enter/exit/current-player lifetime
|
||||
└── CameraFrameController fly/chase/player presentation
|
||||
```
|
||||
|
||||
Concrete names may be narrowed during checkpoint A's source audit, but the
|
||||
ownership boundaries and order do not change. The orchestrator receives a
|
||||
small immutable per-frame input and concrete phase owners. It does not receive
|
||||
a hundred delegates, one mega `IUpdateFrameServices` context, or a service
|
||||
locator, and no transitive update collaborator calls a substantial method back
|
||||
on `GameWindow`.
|
||||
|
||||
This slice does not change simulation rate, object quantum rules, movement
|
||||
speed, interpolation, streaming radii, reveal readiness, portal visuals,
|
||||
particle range, combat cadence, key bindings, camera formulas, render order,
|
||||
or shutdown order. It adds no GL work and does not move retained-UI timers out
|
||||
of their render seam. Slice 7 owns the draw graph; Slice 8 owns final
|
||||
composition and Silk callback cleanup.
|
||||
|
||||
## 2. Retail oracle and accepted adaptation
|
||||
|
||||
The object/network barrier is pinned by
|
||||
[`2026-07-19-r6-update-object-order-pseudocode.md`](../research/2026-07-19-r6-update-object-order-pseudocode.md):
|
||||
|
||||
- `CPhysicsObj::update_object @ 0x00515D10` applies the strict object quantum;
|
||||
- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30` advances the PartArray,
|
||||
position/physics candidate, then processes semantic hooks;
|
||||
- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0` runs Detection → Target →
|
||||
Movement → `CPartArray::HandleMovement` → Position → Particle → Script;
|
||||
- `SmartBox::UseTime @ 0x00455410` advances object/physics time before the
|
||||
inbound queue and then calls `CommandInterpreter::UseTime`;
|
||||
- `gmSmartBoxUI::UseTime @ 0x004D6E30` owns UI/viewport time outside that
|
||||
object barrier.
|
||||
|
||||
At the retail host level, `Client::UseTime @ 0x00411C40` calls
|
||||
`UIElementManager::UseTime` before `SmartBox::UseTime`; the UI time broadcast
|
||||
reaches `gmSmartBoxUI::UseTime`. Retail also publishes player-camera work from
|
||||
the physics/player callback rather than from one post-network camera tail.
|
||||
acdream currently advances teleport/UI-camera presentation after its
|
||||
object/network/command barrier. Slice 6 preserves that existing order as
|
||||
registered TS-53; it does not cite `gmSmartBoxUI` as proof that the current
|
||||
post-network placement is retail-exact.
|
||||
|
||||
acdream preserves two documented presentation adaptations:
|
||||
|
||||
1. only semantic `AnimationDone` executes at retail's in-object hook slot;
|
||||
every other captured animation hook retains the current deferred path after
|
||||
final root, part, and equipped-child poses are published, including
|
||||
particles, audio, lights, translucency, `CallPES`, and default-script
|
||||
chaining (TS-50);
|
||||
2. shared particle simulation and PhysicsScript queues tick once per host
|
||||
update after both worksets rather than once per object's admitted quantum.
|
||||
This also means the current shared Particle → Script order does not reproduce
|
||||
the static workset's retail Script → Particle → hooks order (TS-51).
|
||||
|
||||
Asynchronous landblock streaming has no one-call retail equivalent. Its
|
||||
convergence pre-pass remains before inbound CreateObject dispatch so a newly
|
||||
accepted projection can find a resident bucket in the same frame. This
|
||||
pre-pass does not advance an object's retail clock. `WorldRevealCoordinator`
|
||||
remains the single login/portal readiness owner.
|
||||
|
||||
## 3. Fixed production phase order
|
||||
|
||||
The complete host-update graph is frozen as follows:
|
||||
|
||||
1. retry canonical live-entity teardown tombstones; an `AggregateException` is
|
||||
logged and later phases continue, while any other exception propagates;
|
||||
2. normalize the host delta, advance the shared PES clock once, and publish it;
|
||||
3. advance pending origin retirement/recenter, select the observer, tick
|
||||
streaming, and rebucket rescued persistent entities;
|
||||
4. tick `InputDispatcher`, consume one raw mouse sample, and advance combat
|
||||
attack intent;
|
||||
5. enter the non-reentrant inbound-event envelope and advance the pre-network
|
||||
live-object phase:
|
||||
- local object and pre-network movement/jump output;
|
||||
- queued selection/use output after movement edges;
|
||||
- ordinary live objects;
|
||||
- the distinct static-animation workset;
|
||||
- equipped children and static hook completion;
|
||||
- fades, every deferred non-`AnimationDone` hook, and current effect/light
|
||||
anchors;
|
||||
- particle visibility and particle simulation;
|
||||
- PhysicsScript queues;
|
||||
6. drain inbound session traffic inside one `GpuWorldState.MutationBatch`;
|
||||
7. run the local player's post-network `CommandInterpreter` position check;
|
||||
8. perform the ordinary post-network non-advancing root/child/emitter/light
|
||||
reconcile;
|
||||
9. advance liveness expiry from the absolute client timer;
|
||||
10. activate/tick local teleport transit. A Place edge commits the player and
|
||||
live root, immediately performs its own non-advancing reconcile, then
|
||||
notifies world reveal and continues tunnel state;
|
||||
11. evaluate one-shot player-mode auto-entry;
|
||||
12. update fly or chase/player camera presentation. If the local player was
|
||||
first created by this frame's inbound pass, publish its non-advancing
|
||||
projection, immediately reconcile a second time, then update the camera.
|
||||
|
||||
Those three reconcile edges—ordinary post-network, teleport Place, and
|
||||
inbound-created-player projection—are separate accepted production edges. Any
|
||||
future consolidation is a behavior change with its own oracle, not part of
|
||||
this extraction.
|
||||
|
||||
An invalid/non-positive host delta becomes zero for the PES/object/particle/
|
||||
tunnel delta but never blocks inbound traffic, absolute-time liveness,
|
||||
recenter convergence, or camera-state publication. Raw mouse idle uses its
|
||||
independent monotonic input clock. A missing gameplay camera/input owner never
|
||||
suppresses outer phases; the streaming controller may skip observer-driven
|
||||
streaming when it has no valid live or offline observer, while origin
|
||||
retirement still advances. Devtools keyboard capture pauses player/camera
|
||||
control including autorun; retained chat capture suppresses held WASD while
|
||||
autorun continues; mouse capture ends mouse-look but does not suppress
|
||||
keyboard, combat, network, streaming, teleport, or teardown.
|
||||
|
||||
## 4. Architecture and interfaces
|
||||
|
||||
### 4.1 Per-frame input
|
||||
|
||||
Use an immutable value equivalent to:
|
||||
|
||||
```csharp
|
||||
public readonly record struct UpdateFrameInput(double HostDeltaSeconds);
|
||||
```
|
||||
|
||||
Derived facts are read by their owning controller from stable runtime owners
|
||||
or from a narrowly typed state source. Do not pass `GameWindow`, expose its
|
||||
private fields wholesale, or create a service locator. Mutable player/session
|
||||
owners that legitimately change during login are represented by one focused
|
||||
state/source interface per domain, not anonymous closures into window methods.
|
||||
|
||||
`UpdateFrameClock` is constructed before any materializer/network owner that
|
||||
needs the current PES time. It owns the normalized host/PES clock and exposes a
|
||||
narrow `CurrentScriptTime` source; `_physicsScriptGameTime` does not remain a
|
||||
`GameWindow` field captured by later owners. Absolute liveness time and raw
|
||||
mouse idle time remain distinct monotonic sources and are not derived from the
|
||||
normalized simulation delta.
|
||||
|
||||
### 4.2 Object phase
|
||||
|
||||
`LiveObjectFrameController` owns the body currently in
|
||||
`AdvanceLiveObjectRuntimeCore`. It composes existing focused owners and keeps
|
||||
their retail order; it does not absorb their algorithms or identity maps.
|
||||
`LiveSpatialPresentationReconciler` owns the current no-time refresh body.
|
||||
`RetailLiveFrameCoordinator` depends on those typed owners plus the existing
|
||||
session and local-player owners. Its public `Tick` remains the one explicit
|
||||
object → network → command → reconcile barrier.
|
||||
|
||||
`RetailInboundEventDispatcher` continues wrapping the complete object phase.
|
||||
Reentrant packets queue until the current object's manager tail completes.
|
||||
The hot path remains allocation-free after warmup.
|
||||
|
||||
The extraction inventory includes the complete transitive update graph, not
|
||||
only `RetailLiveFrameCoordinator`: `RetailLocalPlayerFrameController` loses
|
||||
substantial window captures in favor of a focused local-player slot, movement
|
||||
input source, outbound owner, and projection owner;
|
||||
`LiveEntityAnimationPresenter` receives a concrete animation-presentation
|
||||
context instead of retaining `GameWindow` as
|
||||
`ILiveAnimationPresentationContext`; scheduler clock/player/projectile sources
|
||||
are similarly narrow. Existing small value/policy callbacks are acceptable
|
||||
only when they do not call a substantial body back on the window or create a
|
||||
second identity/state owner.
|
||||
|
||||
### 4.3 Streaming phase
|
||||
|
||||
`StreamingFrameController` owns observer selection and the sealed-dungeon
|
||||
gate. It receives the existing `StreamingOriginRecenterCoordinator`,
|
||||
`StreamingController`, `LiveWorldOriginState`/center state, camera/player
|
||||
state, physics current cell, `GpuWorldState`, and `LiveEntityRuntime` through
|
||||
typed seams. It never builds, publishes, or retires a landblock; those remain
|
||||
Slice 5 owners.
|
||||
|
||||
The controller preserves:
|
||||
|
||||
- no pre-login hardcoded-Holtburg streaming;
|
||||
- real-player-origin readiness before first streaming;
|
||||
- destination-origin pinning while in portal space;
|
||||
- authoritative player landblock during the login hold;
|
||||
- offline fly-camera observation only outside live in-world mode;
|
||||
- sealed EnvCell collapse using the cell's own landblock identity;
|
||||
- teleport-hold suppression of the stale source-cell dungeon gate;
|
||||
- rescued-entity rebucketing after the accepted streaming tick.
|
||||
|
||||
### 4.4 Input and camera phases
|
||||
|
||||
`GameplayInputFrameController` owns per-frame dispatcher hold delivery,
|
||||
UI-capture transitions, raw mouse sample/filter/idle handling, combat attack
|
||||
ticking, and the complete mouse-look/cursor-capture lifetime. It is the single
|
||||
owner of mouse-look state, saved cursor mode, begin/end/hide/restore, and
|
||||
movement teardown across press/release, capture change, focus loss, teleport,
|
||||
session reset, player-mode exit, and shutdown. Action press/release callbacks
|
||||
remain on `InputDispatcher` and movement stays owned by
|
||||
`PlayerMovementController`.
|
||||
|
||||
`PlayerModeController` owns enter/exit/toggle construction and the current
|
||||
player-controller, physics-host, chase-camera, and chase-mode slot. Manual
|
||||
input, `PlayerModeAutoEntry`, and local teleport activation all enter through
|
||||
that one owner. Streaming, camera, local-frame, and teleport owners receive
|
||||
narrow read sources from it; no path calls the substantial current
|
||||
`EnterPlayerModeNow`/exit bodies back on `GameWindow` or duplicates player-mode
|
||||
state.
|
||||
|
||||
`CameraFrameController` owns held fly-camera controls, retail chase offsets,
|
||||
post-network player presentation sampling, and both chase-camera updates. It
|
||||
does not own physics, target selection, or camera formulas. The local player
|
||||
created by the inbound pass gets a non-advancing projection before its first
|
||||
draw exactly as today, followed by the current second spatial reconcile before
|
||||
the camera samples that projection.
|
||||
|
||||
### 4.5 Teleport phase
|
||||
|
||||
`LocalPlayerTeleportController` owns only the current local-player transit
|
||||
state machine and presentation edges. It composes
|
||||
`TeleportTransitCoordinator`,
|
||||
`TeleportViewPlaneController`, `PortalTunnelPresentation`,
|
||||
`WorldRevealCoordinator`, streaming priority, and the session LoginComplete
|
||||
send. Checkpoint E first extracts the currently window-owned local aim,
|
||||
destination, hold/forced, activation, placement, reset, and session-clear
|
||||
state into this owner plus a concrete local placement collaborator where
|
||||
needed. It does not reuse `RemoteTeleportPlacement`, and network/session sinks
|
||||
do not retain `AimTeleportDestination` or reset callbacks into `GameWindow`.
|
||||
`LiveEntityLivenessController`, tombstone retry, and `PlayerModeAutoEntry`
|
||||
remain separate focused phases; teleport does not absorb them.
|
||||
|
||||
### 4.6 Composition, thread, and lifetime
|
||||
|
||||
The clock and focused dynamic sources are constructed before their first
|
||||
consumer. The final orchestrator is composed exactly once at the end of a
|
||||
successful `OnLoad`; `OnUpdate` fails fast if that invariant is broken rather
|
||||
than running a nullable partial graph. Every phase mutates state only on the
|
||||
existing update/render thread. Streaming worker completion remains behind
|
||||
`StreamingController.Tick`; the extraction adds no lock, task, or async entry.
|
||||
|
||||
Phase controllers borrow their input, session, streaming, portal, camera, and
|
||||
render-presentation owners; they do not dispose shared dependencies. Any new
|
||||
event subscription has explicit symmetric detach. During shutdown the update
|
||||
collaborators remain alive through the first-stage `LiveSessionController`
|
||||
reset, then retire before their underlying input/camera/portal/streaming
|
||||
resources. Existing retryable shutdown ordering remains authoritative.
|
||||
|
||||
## 5. Checkpoints and acceptance
|
||||
|
||||
### A — contract and guards
|
||||
|
||||
- record the exact production phase graph and every mutable dependency;
|
||||
- correct and pin the retail oracle for physics-hook retention/reentrant
|
||||
append, inactive object behavior, and static Script → Particle → hooks; pin
|
||||
the existing TS-50/TS-51 divergences without reordering runtime behavior in
|
||||
this ownership-only slice;
|
||||
- add an explicit orchestrator order test covering all twelve phases and the
|
||||
three conditional reconcile edges;
|
||||
- pin normalized simulation/PES/particle/tunnel time separately from absolute
|
||||
liveness and raw-mouse idle clocks for NaN, both infinities, non-positive
|
||||
values, and finite values above `float.MaxValue`;
|
||||
- pin missing live/offline camera behavior and the separate devtools keyboard,
|
||||
retained-chat keyboard, and mouse-capture contracts;
|
||||
- pin teardown retry success, `AggregateException` log-and-continue, other
|
||||
exception propagation, and next-frame retry;
|
||||
- add source guards that prevent `OnUpdate` or the coordinator from regaining
|
||||
substantial callback bodies, and prevent any transitive update owner from
|
||||
retaining `GameWindow` or one mega service context;
|
||||
- verify current production traces before cutover.
|
||||
|
||||
### B — object/network barrier
|
||||
|
||||
- move the pre-network animation/effect body into
|
||||
`LiveObjectFrameController`;
|
||||
- move no-time root/child/effect refresh into
|
||||
`LiveSpatialPresentationReconciler`;
|
||||
- replace coordinator callbacks with typed collaborators;
|
||||
- preserve semantic/visual hook separation, owner-incarnation revalidation,
|
||||
static-after-ordinary order, ordinary Particle-before-Script, the current
|
||||
shared-tail TS-51 adaptation, and once-per-host tails;
|
||||
- delete `AdvanceLiveObjectRuntimeCore` and
|
||||
`ReconcileLiveObjectSpatialPresentation` from `GameWindow`.
|
||||
|
||||
Focused tests cover `RetailObjectActivityGate` and
|
||||
`RetailObjectQuantumClock`: parented/cell-less/Frozen suspension, inactive
|
||||
Particle/Script-only time, Hidden+Active adjustment/hooks/manager time, and
|
||||
separate ordinary epsilon/Min/Max/Huge plus static epsilon/Huge boundaries.
|
||||
They also cover incomplete physics-hook
|
||||
retention, same-drain animation-hook append, deferred `CallPES`/default-script/
|
||||
translucency routing, physics-hook append deferred until the next drain,
|
||||
completed physics-hook removal preserving the snapshotted successor, static
|
||||
hooks, callback GUID reuse, and reentrant inbound packets. Particle then Script
|
||||
must run exactly once for an empty world,
|
||||
multiple ordinary/static owners, invalid delta, and reentrant inbound work;
|
||||
the one published PES clock remains visible from hook capture through the
|
||||
script tick.
|
||||
|
||||
Checkpoint B removes the coordinator, animation presenter/scheduler, runtime
|
||||
view, static-root, and projectile callbacks into `GameWindow`. The callback
|
||||
composition still retained by `RetailLocalPlayerFrameController` is explicitly
|
||||
deferred: checkpoint D owns its input/capture sources and checkpoint F owns its
|
||||
mutable player-mode/controller/session sources. A structural test permits that
|
||||
one named legacy owner only; D/F must remove the exception before final cutover.
|
||||
|
||||
### C — streaming frame
|
||||
|
||||
- extract readiness/recenter advancement, observer selection, dungeon gate,
|
||||
streaming tick, and rescued projection rebucketing;
|
||||
- test login-before-player-spawn, outdoor/dungeon/portal-hold/offline observer
|
||||
cases, missing live/offline camera sources, and cell-landblock identity;
|
||||
- prove same-frame streaming completion precedes CreateObject projection into
|
||||
the resident bucket, unknown pre-login origin enqueues nothing, and recenter
|
||||
retirement continues while ordinary streaming is gated;
|
||||
- verify no build/publication/retirement ownership leaks back from Slice 5.
|
||||
|
||||
### D — input frame
|
||||
|
||||
- extract dispatcher tick, UI mouse-capture edge, raw sample/filter/idle path,
|
||||
combat intent tick, and the one cross-lifetime mouse-look/cursor owner;
|
||||
- test capture entry while mouse-look is active, zero-idle drift stop,
|
||||
production action ordering, devtools-vs-chat autorun semantics, mouse-only
|
||||
capture, and focus/teleport teardown;
|
||||
- prove every non-input phase still runs under each capture mode;
|
||||
- retain diagnostic semantic input as the unattended connected gate seam.
|
||||
|
||||
### E — local teleport and post-network world frame
|
||||
|
||||
- extract the currently window-owned local-player teleport state and every
|
||||
aim/destination/activation/place/reset/session port before moving its tick;
|
||||
- retain liveness and auto-entry as separate orchestrator calls around pending
|
||||
transit activation, readiness/hold, tunnel events, materialization, and
|
||||
reveal completion;
|
||||
- test loaded and pending destinations, forced-readiness boundary, same-cell
|
||||
respawn, LoginComplete ordering, delete/reset, and no second reveal owner;
|
||||
- pin Place → immediate reconcile → reveal and old-GUID liveness candidate →
|
||||
inbound delete/recreate generation safety;
|
||||
- preserve the completed portal visuals and world-reveal barrier unchanged.
|
||||
|
||||
**Completed 2026-07-22.** `LocalPlayerTeleportController` now owns the full
|
||||
local transit lifetime and its immutable network handoff, while
|
||||
`PlayerModeController` is the sole writer of controller, physics-host,
|
||||
player-mode, and chase-camera slots. Local animation, shadow synchronization,
|
||||
sealed-dungeon classification, viewport aspect, and approach-completion
|
||||
handoff are focused collaborators rather than window callbacks. Portal entry
|
||||
retires the one-shot auto-entry guard, host publication is transactional, and
|
||||
teleport work revalidates the live generation after every reentrant edge.
|
||||
Focused App/Core tests cover destination readiness, forced holds, same-cell
|
||||
placement, LoginComplete order, reset/disposal, GUID reuse, reentrancy,
|
||||
host rollback, auto-entry idempotence, and completion-mailbox ordering. The
|
||||
full App suite is green at 2,814 passed / 3 skipped. `GameWindow.cs` is 7,351
|
||||
lines at this checkpoint, down from the 15,723-line campaign baseline.
|
||||
|
||||
### F/G — camera, cutover, and deletion
|
||||
|
||||
- extract player-mode enter/exit/toggle and current-owner state before camera
|
||||
or teleport cutover; retain `PlayerModeAutoEntry` only as its one-shot
|
||||
readiness guard;
|
||||
- extract fly camera, retail chase adjustment, local post-network projection,
|
||||
combat target tracking, and chase-camera publication;
|
||||
- pin inbound-created projection → immediate second reconcile → camera, with
|
||||
exact root/child/emitter/light refresh counts and no duplicate time advance;
|
||||
- compose `UpdateFrameOrchestrator` only after all dependencies exist;
|
||||
- replace `OnUpdate` with profiler scope plus one `Tick` call;
|
||||
- remove obsolete frame fields/facades and prove there is no back-reference to
|
||||
`GameWindow` from an extracted owner;
|
||||
- measure line/field/method count. The target is below 8,000 lines, but the
|
||||
ownership/ordering exit criteria outrank the count.
|
||||
|
||||
**Checkpoint F completed 2026-07-22.** `CameraFrameController` now owns fly
|
||||
held input, retail chase adjustment, the inbound-created-player projection
|
||||
and second reconcile, both chase-camera publications, and combat-target
|
||||
tracking. `RetailLocalPlayerFrameController` and
|
||||
`LocalPlayerProjectionController` resolve current identity, session, world,
|
||||
shadow, and player slots through focused typed runtimes instead of callbacks
|
||||
into `GameWindow`; the camera receives only the two-read player-presentation
|
||||
seam. Projection and frame owners remain composition locals, so the window
|
||||
retains no duplicate lifecycle state. Focused Release tests are green at
|
||||
37/37, the App suite at 2,820 passed / 3 skipped, and the full suite at 7,179
|
||||
passed / 5 skipped. All three corrected-diff reviews are clean.
|
||||
`GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line
|
||||
campaign baseline.
|
||||
|
||||
**Checkpoint G completed 2026-07-22.** `GameWindow.OnUpdate` now owns only the
|
||||
update profiler scope and one `UpdateFrameOrchestrator.Tick` call. Teardown,
|
||||
absolute liveness time, local timestamp publication, expiry deletion,
|
||||
player-mode auto-entry, live-origin initialization, Hidden PartArray
|
||||
boundaries, remote teleport placement, and all twelve live-entity session
|
||||
packet routes resolve through typed runtime owners; the former transitive
|
||||
window callbacks and obsolete frame fields are gone. The App suite is green
|
||||
at 2,823 passed / 3 skipped. Focused orchestration, lifecycle, presentation,
|
||||
teleport, origin, and session tests are green, and all three corrected-diff
|
||||
reviews are clean. `GameWindow.cs` is 7,026 raw lines / 241 fields / 108
|
||||
methods, down 1,785 lines and 45 methods from the Slice-6 baseline and 8,697
|
||||
lines (55.3%) from the 15,723-line pre-extraction class.
|
||||
|
||||
### H — release and connected gates
|
||||
|
||||
After all three independent corrected-diff reviews are clean:
|
||||
|
||||
1. run every focused App/Core/Net/UI test project;
|
||||
2. run `dotnet build AcDream.slnx -c Release`;
|
||||
3. run the full Release suite;
|
||||
4. run the connected lifecycle/reconnect gate;
|
||||
5. run the synchronized nine-stop resource soak with movement, turn, jump,
|
||||
combat, portal materialization waits, fatal-log scan, and graceful close;
|
||||
6. compare lifecycle, outbound movement, materialization, entity/animation,
|
||||
frame-profile, resource, and teardown traces with the Slice 5 baseline;
|
||||
7. audit the divergence register, architecture, milestones, roadmap, issues,
|
||||
`AGENTS.md`, `CLAUDE.md`, and durable memory.
|
||||
|
||||
The slice requires no new visual behavior. If connected screenshots or traces
|
||||
change, return to the responsible checkpoint; do not defer the difference to
|
||||
Slice 7.
|
||||
|
||||
**Completed 2026-07-22.** The complete Release suite is green at 7,182 passed /
|
||||
5 fixture or environment skips. The deterministic lifecycle/reconnect gate
|
||||
passed in 314.195 seconds across capped login/travel checkpoints, graceful close,
|
||||
and a fresh-process reconnect. Its only warning was the expected set of 25
|
||||
world-edge empty landblocks. The synchronized nine-stop resource soak passed in
|
||||
393.581 seconds with movement, turn, jump, combat, teleport waits, fatal-log scan,
|
||||
and graceful exit. The final Caul plateau held the same 21,025 entity and 13
|
||||
animation-owner counts as the earlier Caul return, with update-frame p95 at
|
||||
0.8 ms; working/private memory increased by 112.2/111.0 MiB across that retained
|
||||
route, inside the existing deterministic gate. The soak's only diagnostics were
|
||||
35 known DAT-driven missing VFX table/emitter records and the same 25 expected
|
||||
world-edge misses. The divergence audit keeps TS-50, TS-51, and TS-53 unchanged
|
||||
and found no new behavior adaptation. Final `GameWindow` size is 7,026 raw
|
||||
lines / 241 fields / 108 methods, versus 8,811 / 247 / 153 at Slice 6 entry and
|
||||
15,723 / 278 / 205 at the campaign baseline.
|
||||
|
||||
## 6. Review gate for every checkpoint
|
||||
|
||||
After each implementation checkpoint:
|
||||
|
||||
1. run focused tests and inspect the complete diff;
|
||||
2. retail-conformance review checks named functions, exact order, Hidden/
|
||||
quantum semantics, and adaptation boundaries;
|
||||
3. architecture review checks dependency direction, absence of a replacement
|
||||
god object/service locator, same-thread mutation, and symmetric teardown;
|
||||
4. adversarial review checks independent clocks, callback reentrancy,
|
||||
owner/GUID reuse, login/portal transitions, capture modes, missing observer
|
||||
sources, teardown failures, and early-return phase suppression;
|
||||
5. fix every confirmed root cause and repeat review until clean;
|
||||
6. build/test Release and commit the checkpoint before starting the next one.
|
||||
|
||||
## 7. Divergence and documentation bookkeeping
|
||||
|
||||
TS-50, TS-51, and the newly documented existing host-order TS-53 remain until
|
||||
their exact adaptations are ported; this slice must not overclaim retail
|
||||
parity. IA-14/AD-22 and the lifetime/resource rows are audited against the
|
||||
final owner graph but changed only if their mechanism actually changes. Any
|
||||
newly discovered unavoidable adaptation gets a register row in the same
|
||||
commit. The Slice 6 closeout updates the code-structure table,
|
||||
roadmap, milestones, session docs, and
|
||||
`project_gamewindow_decomposition.md` with exact commits, test counts, gates,
|
||||
and final metrics.
|
||||
|
|
@ -0,0 +1,602 @@
|
|||
# GameWindow Slice 7 — render-frame orchestration
|
||||
|
||||
**Status:** Complete 2026-07-22.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 7.
|
||||
**Baseline:** `9512404e`; `GameWindow.cs` is 7,026 raw lines, 241 fields, and
|
||||
108 methods. The Release suite passes 7,182 tests / 5 fixture or environment
|
||||
skips. The 314-second lifecycle/reconnect gate and 394-second synchronized
|
||||
nine-stop resource soak pass.
|
||||
**Behavior rule:** This is an ownership extraction. Preserve every accepted
|
||||
world/PView/alpha/private-viewport/UI draw edge, render-thread publication
|
||||
edge, GL-state boundary, and GPU-flight failure contract. Do not combine the
|
||||
extraction with a render-quality change, visibility correction, frame-order
|
||||
port, or resource-lifetime redesign.
|
||||
|
||||
## Progress ledger
|
||||
|
||||
- [x] A — freeze the complete render graph, correct the architecture SSOT, and
|
||||
introduce data-only frame contracts plus deterministic order/failure tests.
|
||||
- [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf
|
||||
owners while `GameWindow` still orders the frame.
|
||||
- [x] C — extract frame-resource begin/upload/publication and live display,
|
||||
and weather/display foundations that do not depend on camera/root
|
||||
classification.
|
||||
- [x] D — extract frame-data classification and reusable world scratch without
|
||||
duplicating live identity, spatial, visibility, or resource ownership; then
|
||||
run the dependent audio, sky, lighting, and listener preparation.
|
||||
- [x] E — replace `RetailPViewDrawContext`'s callback bag with a typed
|
||||
`RetailPViewPassExecutor` and data-only inputs.
|
||||
- [x] F — extract `WorldSceneRenderer`, including both shared-alpha scopes,
|
||||
PView/fallback world branches, particles, debug world draw, and completion.
|
||||
- [x] G — compose `RenderFrameOrchestrator`, cut `GameWindow.OnRender` to one
|
||||
handoff, and delete obsolete frame bodies, fields, helpers, and callbacks.
|
||||
- [x] H — corrected-diff reviews, full Release suite, connected lifecycle and
|
||||
soak gates, framebuffer comparison, documentation, memory, and metrics.
|
||||
|
||||
Every checked checkpoint is one bisectable architectural commit. A checkpoint
|
||||
does not complete while a new owner merely delegates a substantial body back to
|
||||
`GameWindow`.
|
||||
|
||||
## 1. Outcome and non-goals
|
||||
|
||||
At slice exit, `GameWindow.OnRender` supplies one immutable delta/viewport input
|
||||
to `RenderFrameOrchestrator`. The orchestrator owns the GPU-flight transaction
|
||||
and composes focused frame-resource, world-scene, private-presentation, and
|
||||
diagnostic phases. `GameWindow` remains the construction and shutdown shell; it
|
||||
does not keep the draw graph, PView pass callbacks, frame scratch, paperdoll
|
||||
assembly, or performance-title body.
|
||||
|
||||
The target ownership graph is:
|
||||
|
||||
```text
|
||||
RenderFrameOrchestrator
|
||||
├── RenderFrameResourceController GPU slot + resource BeginFrame/upload
|
||||
├── WorldRenderFrameBuilder camera/root/light/classification facts
|
||||
├── WorldSceneRenderer accepted world draw graph
|
||||
│ ├── RetailPViewRenderer visibility/order owner
|
||||
│ ├── RetailPViewPassExecutor concrete GL pass implementation
|
||||
│ └── WorldRenderDiagnostics probes/debug counters/signatures
|
||||
├── PrivatePresentationRenderer portal + paperdoll + retained UI + ImGui
|
||||
│ ├── PaperdollFramePresenter
|
||||
│ └── DevToolsFramePresenter
|
||||
└── RenderFrameDiagnosticsController title/resource/frame snapshots
|
||||
```
|
||||
|
||||
Names may narrow as code lands, but the ownership boundaries do not collapse
|
||||
into one replacement god object. No new owner may receive `GameWindow`, a broad
|
||||
service locator, or an anonymous list of callbacks into the window.
|
||||
|
||||
This slice does **not** change:
|
||||
|
||||
- the viewer-cell render root or player-cell lighting root;
|
||||
- synthetic outdoor-cell behavior or the null-root safety path;
|
||||
- portal flood, building pre-gates, clip-plane/scissor limits, punch/seal
|
||||
depth behavior, shell lift, viewcone culling, or draw distances;
|
||||
- shared-alpha sort keys, scope boundaries, blend/cull behavior, or immediate
|
||||
EnvCell/private alpha;
|
||||
- weather, fog, point-light selection, particle visibility/range, terrain
|
||||
quality, FOV, frame pacing, or audio-listener formulas;
|
||||
- portal-space, paperdoll, retained gameplay UI, ImGui, or screenshot visuals;
|
||||
- resource ownership, shutdown/disposal order, or three-frame GPU retirement;
|
||||
- TS-53's retained-UI time placement on the render seam.
|
||||
|
||||
Issue #225's translucent lifestone/particle comparison remains a separate user
|
||||
visual gate. Passing this structural slice must not mark it resolved.
|
||||
|
||||
## 2. Retail oracle and accepted adaptations
|
||||
|
||||
This extraction reuses the existing named-retail research rather than
|
||||
reinterpreting the draw algorithms:
|
||||
|
||||
- `SceneTool::BeginScene @ 0x0043DAD0` establishes the black scene-clear
|
||||
baseline used by portal CreatureMode replacement;
|
||||
- `SmartBox::DrawNoBlit @ 0x00454C20` updates the viewer and invokes the normal
|
||||
world render before post-world selection work;
|
||||
- `SmartBox::RenderNormalMode @ 0x00453AA0` owns landscape/inside dispatch and
|
||||
the final retail alpha flush;
|
||||
- `RenderDeviceD3D::DrawInside @ 0x0059F0D0`,
|
||||
`PView::DrawInside @ 0x005A5860`, and
|
||||
`PView::ConstructView @ 0x005A57B0` own the portal traversal;
|
||||
- `PView::DrawCells @ 0x005A4840` fixes the outside-landscape → alpha flush →
|
||||
conditional depth clear → exit masks → shells → object-list order;
|
||||
- `LScape::draw @ 0x00506330`, `GameSky::Draw @ 0x00506FF0`,
|
||||
`DrawBuilding @ 0x0059F2A0`, `DrawBlock @ 0x005A17C0`, and
|
||||
`DrawObjCellForDummies @ 0x005A0760` constrain the landscape/building/cell
|
||||
phases;
|
||||
- `AddMeshToAlphaList @ 0x0059C230` and
|
||||
`FlushAlphaList @ 0x0059D2E0` constrain alpha submission/flush boundaries;
|
||||
- `CPhysicsPart::UpdateViewerDistance @ 0x0050E030` and
|
||||
`CShadowPart::insertion_sort @ 0x006B5130` constrain transformed DAT
|
||||
SortCenter/CYpt and stable far-to-near ordering;
|
||||
- `CreatureMode::Render @ 0x004529D0` constrains portal and paperdoll private
|
||||
viewports;
|
||||
- `SceneTool::EndFrame @ 0x0043FB30` proves retail gameplay UI precedes retail
|
||||
profiler/debug overlays; placing ImGui above gameplay UI is acdream's IA-12
|
||||
modern coexistence adaptation, not literal Keystone parity.
|
||||
|
||||
The readable pseudocode oracle is
|
||||
[`docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md`](../research/2026-06-05-retail-pview-indoor-render-pseudocode.md),
|
||||
with the current full reference in
|
||||
[`docs/research/2026-06-02-retail-render-pipeline-full-reference.md`](../research/2026-06-02-retail-render-pipeline-full-reference.md).
|
||||
Shared-alpha ordering is pinned separately by
|
||||
[`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`](../research/2026-07-18-retail-shared-alpha-list-pseudocode.md).
|
||||
|
||||
This slice preserves, rather than retires, the registered modern adaptations:
|
||||
IA-8/9/10/12/14/15; AD-16/17/19/24;
|
||||
AP-21/32/33/34/35/85/92/115/116/117;
|
||||
TS-6/7; and TS-53. Source pointers move with ownership in the same commit, but
|
||||
mechanism/risk language changes only if behavior actually changes.
|
||||
|
||||
## 3. Frozen production render order
|
||||
|
||||
The complete accepted order is:
|
||||
|
||||
1. call `GpuFrameFlightController.BeginFrame` outside the render `try`;
|
||||
2. begin the composite-texture frame and cache tick;
|
||||
3. begin, in order, dispatcher, EnvCell, portal depth, world text, retained-UI
|
||||
text, clip, terrain, lighting UBO, then profiler frame state for the current
|
||||
GPU slot;
|
||||
4. select the portal replacement viewport; reset particle visibility when it
|
||||
is active;
|
||||
5. choose portal black or normal fog clear color, force depth writes, clear
|
||||
color/depth/stencil, and establish CW/back-face frame-global state;
|
||||
6. run GL-state/surface diagnostics;
|
||||
7. advance render-thread publication in order: `WbMeshAdapter.Tick`, destination
|
||||
reveal preparation/evaluation, then `ParticleRenderer.BeginFrame`;
|
||||
8. begin ImGui, tick weather, and begin retail selection-scene accumulation;
|
||||
9. when a normal camera/world viewport is active:
|
||||
- begin the one frame-scoped `RetailAlphaQueue` collection; its landscape
|
||||
`Flush` drains the first sub-scope and `EndFrame` later drains the final
|
||||
world sub-scope without a second begin;
|
||||
- resolve camera/projection/frustum and begin particle/terrain visibility;
|
||||
- publish world-reveal visibility/completion;
|
||||
- preview audio/display settings and publish the listener;
|
||||
- resolve player lighting cell and collided viewer render cell;
|
||||
- build prior-visible-cell point-light candidacy, sun/fog/lighting UBO, and
|
||||
animated/equipped classification;
|
||||
- construct the synthetic outdoor root/building candidates when required;
|
||||
- run the null-root safety world or the one `RetailPViewRenderer.DrawInside`
|
||||
graph;
|
||||
- draw the post-world Scene particles/weather fallback and debug geometry;
|
||||
- sample current-frame DebugVM visible-landblock and nearest-collision facts
|
||||
before the developer UI can consume them;
|
||||
- close the final shared-alpha scope and publish terrain/cell particle
|
||||
visibility;
|
||||
10. complete retail selection-scene accumulation;
|
||||
11. draw the portal CreatureMode replacement viewport;
|
||||
12. render the private paperdoll FBO before its 2-D widget blit;
|
||||
13. tick/update-cursor/draw retained gameplay UI;
|
||||
14. render ImGui menus and developer panels above gameplay UI;
|
||||
15. capture a requested default-framebuffer screenshot;
|
||||
16. publish window-title, resource-dump, and frame-profile diagnostics;
|
||||
17. close the exact GPU frame once in `finally`.
|
||||
|
||||
Within `RetailPViewRenderer.DrawInside`:
|
||||
|
||||
1. construct the main PView; merge per-building exterior floods only for the
|
||||
synthetic outdoor root and build separate look-in frames for interior roots;
|
||||
2. assemble/upload the one clip arena, prepare EnvCell batches, partition
|
||||
statics/dynamics, and classify outside-stage dynamics;
|
||||
3. draw each outside slice early: sky, terrain, and outdoor statics;
|
||||
4. draw building look-ins: all far-Z punches, opaque shells, then transparent
|
||||
shells plus statics/dynamics/emitters;
|
||||
5. draw outside-stage dynamic meshes, Scene particles, and weather late;
|
||||
6. draw one interior-root unattached Scene-particle pass;
|
||||
7. flush the **landscape** shared-alpha scope;
|
||||
8. for an interior root, perform the full depth clear, then write true-depth
|
||||
exit seals; for an outdoor root, retain world depth and use far-Z entry
|
||||
punches;
|
||||
9. draw opaque EnvCell shells once, then transparent shell cells immediate
|
||||
far-to-near;
|
||||
10. draw cell statics as one cross-cell batch, then their particles;
|
||||
11. draw the surviving main-flood dynamics last, then their particles; look-in
|
||||
and outside-stage dynamics remain in their earlier landscape phases.
|
||||
|
||||
The landscape alpha flush must never move across the depth clear. The final
|
||||
world alpha close must never move after portal/paperdoll private viewports.
|
||||
|
||||
## 4. Architecture and interfaces
|
||||
|
||||
### 4.1 Frame transaction
|
||||
|
||||
Use immutable values equivalent to:
|
||||
|
||||
```csharp
|
||||
internal readonly record struct RenderFrameInput(
|
||||
double DeltaSeconds,
|
||||
int ViewportWidth,
|
||||
int ViewportHeight);
|
||||
|
||||
internal readonly record struct WorldRenderFrameOutcome(
|
||||
int VisibleLandblocks,
|
||||
int TotalLandblocks,
|
||||
bool NormalWorldDrawn);
|
||||
```
|
||||
|
||||
Portal-viewport selection and drawing belong to `PrivatePresentationRenderer`,
|
||||
which runs after the world phase. If a combined public outcome becomes useful,
|
||||
the top-level orchestrator composes it only after private presentation; the
|
||||
world owner never claims that a later viewport was drawn.
|
||||
|
||||
`RenderFrameOrchestrator` receives a small fixed set of typed phase owners. It
|
||||
does not receive every renderer separately, a mutable frame-services bag, or
|
||||
`GameWindow`.
|
||||
|
||||
`BeginFrame` remains outside the `try`: if it fails, no `EndFrame` is attempted.
|
||||
After begin succeeds, every failure closes exactly once. Render failure plus
|
||||
close failure becomes the current explicit `AggregateException`; close-only
|
||||
failure propagates directly. Alpha, particle-visibility, and selection-scene
|
||||
completion retain their current non-finally behavior; changing that recovery
|
||||
contract requires separate approval and evidence.
|
||||
|
||||
### 4.2 Borrowed resources and shutdown
|
||||
|
||||
The frame orchestrator and phase owners borrow the already-created GL/render
|
||||
owners and do not dispose them. The developer-tools backend is itself the
|
||||
canonical owner of its ImGui bootstrap and disposes that bootstrap when the
|
||||
existing retryable shutdown transaction retires the backend; the frame
|
||||
presenter only borrows it. Shutdown keeps its proven dependency order: wait for
|
||||
submitted work; withdraw UI/render publications; release textures and mesh
|
||||
owners; then dispose GL backing stores, with `GpuFrameFlightController` last
|
||||
among render resources. Slice 8 may group construction/disposal after the
|
||||
render cutover is stable.
|
||||
|
||||
`DisplayFramePacingController` is the focused shared owner for requested VSync,
|
||||
live display preview, monitor/window callbacks, and the separate
|
||||
`OnFrameRendered` pacing event. Render preparation and window lifecycle wiring
|
||||
invoke that typed owner directly; neither calls back into a substantial
|
||||
`GameWindow` method. Its existing disposal remains in the Slice 7 shutdown
|
||||
transaction.
|
||||
|
||||
### 4.3 World frame data
|
||||
|
||||
`WorldRenderFrameBuilder` owns reusable classification scratch:
|
||||
|
||||
- animated/equipped entity IDs;
|
||||
- synthetic outdoor node and candidate building cells;
|
||||
- previous-frame drawable cells used by point-light candidacy;
|
||||
- camera/root/classification facts with one-frame borrowed lifetime.
|
||||
|
||||
The builder owns only facts available before PView execution. Slice-local
|
||||
visible/outdoor/unattached particle-owner IDs belong to
|
||||
`RetailPViewPassExecutor`. The current interior partition is produced by
|
||||
`RetailPViewRenderer.DrawInside` and remains in its borrowed result/diagnostic
|
||||
snapshot; it is never stored as stale pre-draw builder state.
|
||||
|
||||
It queries canonical `LiveEntityRuntime`, `GpuWorldState`, player-mode/camera,
|
||||
physics cell graph, cell visibility, and landblock presentation sources. It
|
||||
does not copy their dictionaries or create a second visibility computation.
|
||||
Returned collections are borrowed until the next build; tests pin this
|
||||
lifetime and consumers cannot retain them.
|
||||
|
||||
### 4.4 PView pass execution
|
||||
|
||||
Replace the current `RetailPViewDrawContext` callback bag with a data-only
|
||||
`RetailPViewFrameInput`, a typed cell-visibility source, and one explicit
|
||||
`IRetailPViewPassExecutor`/`RetailPViewPassExecutor`. The executor owns the GL
|
||||
resources needed for these named operations:
|
||||
|
||||
```text
|
||||
SetTerrainClip
|
||||
DrawLandscapeSlice
|
||||
DrawLandscapeSliceLate
|
||||
ClearInteriorDepth
|
||||
DrawExitPortalMask
|
||||
DrawLookInPortalPunch
|
||||
DrawUnattachedSceneParticles
|
||||
FlushLandscapeAlpha
|
||||
DrawCellParticles
|
||||
DrawDynamicsParticles
|
||||
EmitDiagnostics
|
||||
```
|
||||
|
||||
`RetailPViewRenderer` remains the retail ordering owner and calls these typed
|
||||
operations. The executor may not call `GameWindow` or make independent
|
||||
visibility/order decisions. Existing borrowed `RetailPViewFrameResult` scratch
|
||||
semantics remain explicit.
|
||||
|
||||
### 4.5 Presentation and diagnostics
|
||||
|
||||
`PaperdollFramePresenter` owns dirty state, DAT-backed clone/re-dress, pose
|
||||
selection/application, and FBO rendering. Inventory/session paths call
|
||||
`MarkDirty`; they do not mutate its internals.
|
||||
|
||||
`PrivatePresentationRenderer` owns portal viewport, paperdoll, retained UI,
|
||||
ImGui, and screenshot order. A focused panel-layout/devtools owner handles menu
|
||||
actions and is also reused by framebuffer resize; it cannot call a substantial
|
||||
window method.
|
||||
|
||||
`WorldRenderDiagnostics` owns pass-local PView/GL/scissor state, render
|
||||
signatures, and world-pass timings. `RenderFrameDiagnosticsController` owns
|
||||
FPS/frame-time accumulation, final visible-landblock counters, performance
|
||||
title/resource publication, and the public render-frame snapshot. Only
|
||||
render-derived DebugVM providers move in Slice 7. Update/session/streaming
|
||||
providers and the cross-domain world-lifecycle resource sampler stay at the
|
||||
composition boundary until Slice 8 rather than turning either diagnostics
|
||||
owner into a replacement service locator. Diagnostics observe the frame; they
|
||||
never choose a draw branch or mutate canonical world ownership.
|
||||
|
||||
## 5. Implementation checkpoints
|
||||
|
||||
### A — contracts, architecture, and oracles
|
||||
|
||||
- correct the stale render-pipeline and per-frame architecture sections;
|
||||
- add `RenderFrameInput`, phase interfaces, and a recording orchestrator;
|
||||
- pin the outer resource/world/private/diagnostic order across normal,
|
||||
portal-replacement, login-wait, and screenshot outcome shapes; defer the
|
||||
concrete indoor/outdoor/null-root branch tests to the world builder and scene
|
||||
owner in D/F; camera is an initialized runtime invariant, so the slice does
|
||||
not invent a new graceful missing-camera path;
|
||||
- pin the exact GPU begin/end exception matrix;
|
||||
- add source guards against `GameWindow` back-references, mega contexts, and a
|
||||
new PView delegate bag;
|
||||
- capture the current connected framebuffer/resource baseline.
|
||||
|
||||
Completed 2026-07-22. `RenderFrameOrchestrator` now owns the inert five-owner
|
||||
outer transaction contract; `GpuFrameFlightController` implements its narrow
|
||||
lifetime seam. Twenty-six focused render/GPU tests pin value-only contracts,
|
||||
required-owner construction, exact phase prefixes, per-phase input propagation,
|
||||
and every begin/render/close failure combination. Release build and the full
|
||||
suite pass (7,199 passed / 5 skipped after adding 17 tests). All three
|
||||
corrected-diff reviews are clean; production `GameWindow.OnRender` is unchanged.
|
||||
|
||||
### B — leaf presentation and diagnostics
|
||||
|
||||
- extract paperdoll refresh/pose/dirty/FBO ownership;
|
||||
- extract panel-layout reuse and devtools menu/panel rendering;
|
||||
- extract pass-local render signature, PView/GL/scissor probes, and timing state
|
||||
into `WorldRenderDiagnostics`; extract FPS, final counts, title/resource
|
||||
publication, and the public render snapshot into
|
||||
`RenderFrameDiagnosticsController`;
|
||||
- preserve the pre-presentation render-derived DebugVM sampling site separately
|
||||
from the post-screenshot title/resource publication site; leave non-render
|
||||
DebugVM providers and cross-domain world-lifecycle sampling at composition;
|
||||
- keep call sites in their current `GameWindow.OnRender` positions.
|
||||
|
||||
Completed 2026-07-22. Paperdoll clone/re-dress/pose/FBO work now belongs to
|
||||
`PaperdollFramePresenter`; retained surfaces are narrow and optional, while
|
||||
live identity and DAT pose application resolve through focused adapters.
|
||||
`DevToolsFramePresenter` owns the ImGui frame, menus, panels, layout, and input
|
||||
actions while borrowing the bootstrap; `ImGuiDevToolsFrameBackend` explicitly
|
||||
owns and disposes that bootstrap at shutdown.
|
||||
`WorldRenderDiagnostics`, `DebugVmRenderFactsPublisher`, and
|
||||
`RenderFrameDiagnosticsController` own the former render probes, debug facts,
|
||||
title/resource cadence, and public snapshot. The production handoff now reports
|
||||
the observed login/world and screenshot outcomes, preserves the original shared
|
||||
TERRAIN→FRAME diagnostic transaction, and keeps the devtools-off scan path
|
||||
allocation-free. `GameWindow.cs` is 6,270 raw lines, 60.1% below the 15,723-line
|
||||
campaign baseline. Sixty-two focused tests, the Release build, and the complete
|
||||
suite pass (7,255 passed / 5 skipped). Retail, architecture, and adversarial
|
||||
corrected-diff reviews are clean.
|
||||
|
||||
### C — frame resources and live preparation
|
||||
|
||||
- extract ordered per-resource `BeginFrame` calls, clear/state establishment,
|
||||
histogram probe, mesh upload, reveal evaluation, and particle begin;
|
||||
- extract weather accumulation and display foundations that do not consume the
|
||||
camera/root classification, preserving their current render-time positions;
|
||||
- consolidate requested-VSync/live-preview, monitor callbacks, and
|
||||
`OnFrameRendered` pacing through `DisplayFramePacingController`, while
|
||||
leaving its disposal in the existing shutdown transaction;
|
||||
- prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing;
|
||||
- prove dispatcher begin occurs once for the whole world/paperdoll frame.
|
||||
|
||||
Completed 2026-07-22. `RenderFrameResourceController` now owns the exact
|
||||
resource-begin → atmosphere/GL-clear → live-upload/reveal/particle transaction,
|
||||
with a single captured GPU slot shared by every phase. Focused sources own the
|
||||
login reveal cell, teleport render state, upload timing, and render-time weather
|
||||
clock. `DisplayFramePacingController` owns requested VSync, the event-refreshed
|
||||
monitor-rate cache, software pacing, and the post-render callback without adding
|
||||
a monitor query to the frame hot path. Borrowed frame owners are withdrawn before
|
||||
render teardown; portal fallback ownership transfers before the throwable sink
|
||||
bind; pacing releases its borrowed surface/profiler before their owners close.
|
||||
`GameWindow.cs` is 6,181 raw lines, 60.7% below the 15,723-line campaign
|
||||
baseline. Twenty-one focused tests, the Release build, and the complete suite
|
||||
pass (7,269 passed / 5 skipped). Retail, architecture, and adversarial
|
||||
corrected-diff reviews are clean.
|
||||
|
||||
### D — world frame builder
|
||||
|
||||
- move animated/equipped classification, camera/root selection, outdoor-node
|
||||
building gather, and prior-visible-cell light scratch out of the window;
|
||||
- after those facts exist, run focused audio/listener, live preview, sky PES,
|
||||
sun/fog/point-light, and lighting-UBO preparation; do not build an interim
|
||||
`GameWindow` mega-context;
|
||||
- keep PView-generated particle-owner sets in `RetailPViewPassExecutor` and the
|
||||
post-draw partition in the borrowed PView result/diagnostic snapshot;
|
||||
- pin viewer-cell versus player-cell roles, portal replacement, login wait,
|
||||
fallback/null-root, sealed/SeenOutside, and borrowed scratch reset;
|
||||
- preserve allocation-free warmed frames and hysteretic scratch retention.
|
||||
|
||||
Completed 2026-07-22. `WorldRenderFrameBuilder` now owns the ordered camera,
|
||||
visibility, live settings/listener, player-lighting versus collided-viewer root,
|
||||
sky/lighting/fog, animated-ID, and building-candidate preparation. Reusable
|
||||
animated/building/light-cell collections retain their explicit one-frame
|
||||
borrowed lifetime; PView drawable-cell feedback returns through the builder and
|
||||
is copied before the renderer reuses its own scratch. The diagnostic sky-PES
|
||||
experiment moved to `SkyPesFrameController`, and fog startup overrides now enter
|
||||
through typed `RuntimeOptions`. The frame graph is withdrawn before its borrowed
|
||||
equipped/effect/audio owners during shutdown. `GameWindow.cs` is 5,715 raw
|
||||
lines, 63.6% below the 15,723-line campaign baseline. Twenty-nine focused tests,
|
||||
the Release build, and the complete suite pass (7,281 passed / 5 skipped).
|
||||
Retail, architecture, and adversarial corrected-diff reviews are clean.
|
||||
|
||||
### E — typed PView pass executor
|
||||
|
||||
- replace `Action`/`Func` draw callbacks in `RetailPViewDrawContext`;
|
||||
- move landscape early/late, scissor/clip, depth seal/punch, particle passes,
|
||||
landscape alpha flush, and PView diagnostics to the executor;
|
||||
- add a recording executor test for the complete `DrawInside` call sequence;
|
||||
- verify no executor operation rebuilds portal visibility or retains a borrowed
|
||||
frame result across calls.
|
||||
|
||||
Completed 2026-07-22. `RetailPViewRenderer` is now the retail visibility and
|
||||
ordering owner only; `RetailPViewPassExecutor` owns the concrete clip upload,
|
||||
EnvCell/entity, landscape, portal depth, particle, alpha, and diagnostic pass
|
||||
implementations. The old callback bag and broad window closures are gone.
|
||||
`WorldRenderDiagnostics` now owns every PView print-on-change signature and
|
||||
probe sink, while a focused terrain-diagnostics controller preserves the
|
||||
shared five-second terrain/frame publication transaction across fallback and
|
||||
PView draws. Real `DrawInside` fixtures cover outdoor, interior-exit,
|
||||
no-outside, entity/static/dynamic particle, transparent-shell, look-in, and
|
||||
second-call scratch-reset behavior; structural tests pin concrete forwarding,
|
||||
no delegate/back-reference leakage, no borrowed frame/result retention, and
|
||||
early shutdown withdrawal. `GameWindow.cs` is 5,303 raw lines, 66.3% below the
|
||||
15,723-line campaign baseline. Release build and the complete suite pass
|
||||
(7,295 passed / 5 skipped). Retail, architecture, and adversarial
|
||||
corrected-diff reviews are clean.
|
||||
|
||||
### F — world scene owner
|
||||
|
||||
- extract alpha/selection/particle-visibility begin/end and the normal-world
|
||||
conditional;
|
||||
- extract sky/terrain/null-root fallback and unified PView branches;
|
||||
- extract post-world particles/weather and debug collision drawing;
|
||||
- return a small `WorldRenderFrameOutcome` for diagnostics/presentation;
|
||||
- delete the world draw body and helper closure from `GameWindow`.
|
||||
|
||||
Completed 2026-07-22. `WorldSceneRenderer` now owns the normal-world
|
||||
transaction, one shared-alpha frame, retail selection accumulation, PView versus
|
||||
null-root fallback branching, post-world particles/weather, visibility
|
||||
publication, and world diagnostics. `WorldScenePassExecutor`, focused runtime
|
||||
sources, and `WorldSceneDiagnosticsController` carry the concrete GL and data
|
||||
seams without retaining `GameWindow`, delegates, or borrowed PView products.
|
||||
Failed frames abort PView, pass routing, particle visibility, alpha, and
|
||||
selection in reverse ownership order; one shared `RenderFrameGlStateController`
|
||||
restores scissor/stencil/blend/clip/mask/depth/cull/program/buffer state before
|
||||
the next clear. Normal portal-depth exits now restore the same cull-off
|
||||
convention, and alpha cleanup preserves the original draw failure while
|
||||
attempting every source reset. `GameWindow.cs` is 4,765 raw lines, 69.7% below
|
||||
the 15,723-line campaign baseline. Release build and the complete suite pass
|
||||
(7,313 passed / 5 skipped). Retail, architecture, and adversarial
|
||||
corrected-diff reviews are clean.
|
||||
|
||||
### G — final orchestration cutover
|
||||
|
||||
- compose concrete resource, world, private-presentation, and diagnostic owners;
|
||||
- replace `OnRender` with one `RenderFrameOrchestrator.Render` handoff;
|
||||
- remove obsolete fields/helpers/callbacks; prove immediate frame owners retain
|
||||
no direct `GameWindow`/anonymous-delegate back-reference, and document rather
|
||||
than deny recursive paths through borrowed canonical UI/input owners;
|
||||
- preserve the existing shutdown dependency order while making the
|
||||
developer-tools backend's pre-existing bootstrap ownership explicit;
|
||||
- measure line/field/method counts. Ownership decides completion; the expected
|
||||
signal is roughly 5,000–5,500 lines before Slice 8 composition cleanup.
|
||||
|
||||
Completed 2026-07-22. `GameWindow.OnRender` now creates one value-only
|
||||
`RenderFrameInput` and hands the complete frame to `RenderFrameOrchestrator`.
|
||||
Focused preparation, world-scene, private-presentation, and diagnostic owners
|
||||
preserve the accepted resource → ImGui begin → weather → world → portal →
|
||||
paperdoll → retained UI → ImGui submit → screenshot → diagnostics → GPU close
|
||||
order. Recovery now closes an active ImGui frame through the owning Silk
|
||||
controller and restores the exact text-render GL state after success or
|
||||
failure. Screenshot dimensions travel in the frame input rather than through a
|
||||
window callback, and immediate frame owners have no direct `GameWindow` or
|
||||
anonymous-delegate back-reference. `GameWindow.cs` is 4,666 raw lines / 196
|
||||
fields / 70 methods: 11,057 lines (70.3%) below the 15,723-line campaign
|
||||
baseline. The Release build and complete suite pass (7,341 passed / 5 skipped),
|
||||
and retail-conformance, architecture, and adversarial corrected-diff reviews
|
||||
are clean.
|
||||
|
||||
### H — release and connected gates
|
||||
|
||||
After three clean corrected-diff reviews:
|
||||
|
||||
1. run focused App/Core render, PView, alpha, portal, screenshot, and resource
|
||||
tests;
|
||||
2. run `dotnet build AcDream.slnx -c Release`;
|
||||
3. run the complete Release suite;
|
||||
4. run `tools/run-connected-world-lifecycle-gate.ps1 -SkipBuild`;
|
||||
5. run `tools/run-connected-r6-soak.ps1 -SkipBuild`;
|
||||
6. require code-zero graceful exits, exact lifecycle checkpoints/PNGs, no
|
||||
fatal/invariant/reveal/pending-resource failures, and unchanged same-location
|
||||
resource tolerances;
|
||||
7. compare the six existing lifecycle/reconnect PNGs (`capped_login`,
|
||||
`aerlinthe_first`, `facility_hub`, `holtburg_after_dungeon`,
|
||||
`aerlinthe_revisit`, and `uncapped_reconnect`) with the Slice 6 baseline;
|
||||
8. audit architecture, divergence pointers, roadmap, milestones, issues,
|
||||
`AGENTS.md`, `CLAUDE.md`, and durable memory.
|
||||
|
||||
Completed 2026-07-22. The 315.6-second lifecycle/reconnect gate passed at
|
||||
`9d7df1bf` with six valid PNG checkpoints, code-zero graceful capped exit, and
|
||||
fresh-process uncapped reconnect. Side-by-side comparison with the Slice 6
|
||||
artifacts preserved world geometry, UI/paperdoll layering, private viewports,
|
||||
and depth/presentation; only live weather, particle timing, authoritative
|
||||
vitals, and small scripted camera-settling differences changed. The unchanged
|
||||
nine-stop gate then passed in 395.2 seconds with nine materializations, all
|
||||
Caul/Holtburg movement exercises, code-zero graceful shutdown, update p95 at
|
||||
or below 0.8 ms, and same-location Caul plateau deltas of +85.7 MiB working set
|
||||
and +59.3 MiB private memory. Two immediately preceding identical-binary runs
|
||||
crossed the coarse process-residency limit while every other deterministic
|
||||
lifecycle, timing, and shutdown check passed; #232 records the need to add
|
||||
canonical managed/GPU/cache owner snapshots to that gate instead of weakening
|
||||
its leak threshold. Release build and the complete suite remain green at 7,341
|
||||
passed / 5 skipped. Slice 7 is closed with no new retail divergence.
|
||||
|
||||
The accepted baseline at docs-only commit `9512404e` is behavior-equivalent to
|
||||
production cutover `e91f3102`: 7,182 passed / 5 skipped, 314.195-second
|
||||
lifecycle/reconnect, and 393.581-second soak. Exact counters are recorded in
|
||||
`memory/project_gamewindow_decomposition.md` and the Slice 6 closeout ledger.
|
||||
Building/doorway poses, an open paperdoll, transient portal-exit visuals, and
|
||||
#225 remain user visual gates; the stable post-materialization PNGs do not
|
||||
overclaim them.
|
||||
|
||||
## 6. Mandatory review gate
|
||||
|
||||
After every implementation checkpoint, three read-only reviews run:
|
||||
|
||||
1. **Retail conformance:** named anchors, PView/alpha/depth/private-viewport/UI
|
||||
order, and registered adaptation boundaries.
|
||||
2. **Architecture/integration:** focused owner scope, no replacement god object,
|
||||
no service locator/back-callback, render-thread mutation, borrowed lifetime,
|
||||
GL-state symmetry, and shutdown dependency direction.
|
||||
3. **Adversarial:** begin/end failures, missing resources, portal/login
|
||||
branches, stale borrowed scratch, callback reentrancy, frame spikes,
|
||||
mid-frame resource churn, and GUID/session replacement.
|
||||
|
||||
The primary agent fixes confirmed findings at their root, then requests
|
||||
corrected-diff re-review until clean. Each checkpoint then runs focused tests,
|
||||
Release build/full suite, divergence/doc audit, and lands as one commit.
|
||||
|
||||
## 7. Final acceptance matrix
|
||||
|
||||
### Automated
|
||||
|
||||
- exact orchestrator order for normal, login, portal, indoor, outdoor, and
|
||||
fallback frames, plus construction-time rejection of missing required phase
|
||||
owners;
|
||||
- exact GPU begin/end/aggregate failure behavior;
|
||||
- PView landscape/look-in/flush/clear/seal/shell/static/dynamics ordering;
|
||||
- screenshot after retained UI/ImGui, before title/resource/frame-profile
|
||||
diagnostics, and before GPU `EndFrame`;
|
||||
- dispatcher frame begin once across world and paperdoll scopes;
|
||||
- frame scratch reset/reuse after skipped, portal, normal, and failed frames;
|
||||
- no `GameWindow` reference or anonymous draw delegate bag in extracted owners;
|
||||
- existing PView replay, shared-alpha, particle visibility, portal, paperdoll,
|
||||
GPU-fence, screenshot, resource, and structural suites remain green;
|
||||
- Release build and all tests green;
|
||||
- connected lifecycle/reconnect and synchronized soak reports pass.
|
||||
|
||||
### In-client campaign gate
|
||||
|
||||
- outdoor dense scenes, terrain, sky, weather, particles, and selection match;
|
||||
- buildings, doorway look-in/out, depth seals/punches, and dynamics stay stable;
|
||||
- sealed dungeons keep correct visibility and lighting;
|
||||
- portal replacement and destination reveal preserve the accepted flow;
|
||||
- paperdoll, retained gameplay UI, cursor, and ImGui layering are unchanged;
|
||||
- no resource growth, GPU reset, render exception, or shutdown regression.
|
||||
|
||||
## 8. Fixed assumptions
|
||||
|
||||
- all render/GL mutation remains on the existing render thread;
|
||||
- `DatCollection` remains the only DAT reader;
|
||||
- `LiveEntityRuntime`, `GpuWorldState`, cell visibility, session, and resource
|
||||
owners remain canonical and are not copied;
|
||||
- existing render resources are borrowed through Slice 7 and disposed by the
|
||||
current shutdown transaction;
|
||||
- frame-local results and scratch collections are borrowed until the next
|
||||
frame/call unless explicitly copied;
|
||||
- no quality/range/performance tradeoff is introduced to make the extraction
|
||||
easier;
|
||||
- missing optional diagnostic/UI owners skip only their own presentation and
|
||||
never suppress world drawing or GPU frame closure.
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# GameWindow Slice 8 — Checkpoint G Runtime Settings Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Replace `GameWindow`'s duplicated pre-window and `OnLoad` settings paths with
|
||||
one two-phase `RuntimeSettingsController`. The controller owns persistence and
|
||||
live settings state; render, window, audio, UI, and streaming objects remain
|
||||
borrowed targets. This is a structural cutover, not a redesign of the settings
|
||||
format or retail option behavior.
|
||||
|
||||
Two carried behaviors are now explicit rather than accidental. Saved FOV is
|
||||
applied during startup even when developer tools are disabled; previously it
|
||||
was incorrectly gated on construction of `SettingsVM`. The named-retail
|
||||
`/framerate` live toggle and notice remain intact, while acdream's already
|
||||
shipped cross-launch ShowFps persistence is documented as AP-121 rather than
|
||||
being attributed to the retail command body.
|
||||
|
||||
## Frozen behavior and ordering
|
||||
|
||||
1. Construct the controller before `Window.Create` and load Display, Audio,
|
||||
Gameplay, Chat, and the `default` Character bag exactly once.
|
||||
2. Resolve the preset plus environment overrides once for the immutable startup
|
||||
snapshot. `WindowOptions.VSync` comes from the pacing policy initialized with
|
||||
that snapshot; `WindowOptions.Samples` comes from the same resolved quality.
|
||||
3. After window/GL input, camera, DAT effects, and optional audio exist, but
|
||||
before devtools or world/render factories, apply startup settings in this
|
||||
order: monitor-aware pacing, resolution/fullscreen, field of view, then audio.
|
||||
4. Downstream terrain, dispatcher, render-range, and streaming factories read
|
||||
the same startup quality snapshot. The legacy startup stream-radius override
|
||||
remains at its current composition boundary.
|
||||
5. Bind one complete `RuntimeSettingsTargets` only after window, UI (when
|
||||
enabled), terrain atlas, dispatcher, render-range, and streaming exist.
|
||||
Binding performs no display, quality, UI-lock, or audio replay.
|
||||
6. Settings-panel Save preserves its callback order: keybindings, Display,
|
||||
Audio, Gameplay, Chat, Character. Each section retains its existing
|
||||
persistence/error boundary.
|
||||
7. Display Save persists first, applies resolution/fullscreen, publishes the
|
||||
new display snapshot, then reapplies quality. A changed MSAA sample count is
|
||||
logged as restart-required and is not applied to the live GL context.
|
||||
8. Runtime quality reapply preserves the existing order: alpha-to-coverage,
|
||||
terrain anisotropy, render-range publication, streaming reconciliation,
|
||||
completion budget.
|
||||
9. `/framerate`, UI lock, loot permits, and combat preferences mutate the
|
||||
controller's canonical state, synchronize the optional Settings draft using
|
||||
the existing draft-preservation rules, and retain their existing save-error
|
||||
behavior.
|
||||
10. Enter-world changes the active toon key, loads that character bag even when
|
||||
devtools are disabled, and publishes it to the optional Settings view model.
|
||||
Session reset restores the startup/default character context before the
|
||||
later identity-reset operation restores the key to `default`.
|
||||
11. Per-frame display/audio preview reads the optional Settings draft through a
|
||||
typed source. Particle range and FPS visibility read the same source so an
|
||||
unsaved draft still previews exactly as it does today.
|
||||
12. Shutdown first withdraws the Settings view-model and runtime-target loans;
|
||||
the controller never disposes the window, renderer, UI, streaming, audio, or
|
||||
input objects it borrows.
|
||||
|
||||
## Types and ownership
|
||||
|
||||
- `IRuntimeSettingsStorage` is the test seam for the five settings bags.
|
||||
`JsonRuntimeSettingsStorage` is the production owner of the one concrete
|
||||
`SettingsStore` and exposes that store only for retained-window layout
|
||||
persistence.
|
||||
- `RuntimeSettingsSnapshot` is the immutable startup value.
|
||||
- `IRuntimeSettingsStartupTarget` receives the one startup Display/Audio apply.
|
||||
- `IRuntimeSettingsTargets` receives future display-window, quality, and UI-lock
|
||||
changes. `BindRuntimeTargets` stores it without invoking it.
|
||||
- `IRuntimeSettingsPreviewSource` exposes current Display/Audio preview values
|
||||
and whether a live draft exists.
|
||||
- `RuntimeSettingsController` owns persistence, current values, active toon,
|
||||
resolved quality, and the optional `SettingsVM` binding. It also implements
|
||||
the combat settings source; no second gameplay-state mirror remains.
|
||||
- `SilkRuntimeDisplayWindowTarget`, `RuntimeSettingsStartupTargets`, and
|
||||
`RuntimeSettingsTargets` are App-layer adapters over borrowed runtime objects.
|
||||
|
||||
## Automated gates
|
||||
|
||||
- Construction loads every bag once and populates one immutable snapshot.
|
||||
- Startup apply is ordered, exactly once, and independent of GL in controller
|
||||
tests.
|
||||
- Runtime target binding produces zero calls; unbinding prevents future target
|
||||
calls while state and persistence still advance.
|
||||
- Display Save ordering, quality transition, and restart-required MSAA logging.
|
||||
- Quality target receives one complete resolved value per reapply.
|
||||
- Draft preview/cancel, external FPS mutation, UI lock, loot permits, and combat
|
||||
option synchronization.
|
||||
- Toon activate/load/reset/save behavior with and without a Settings view model.
|
||||
- Save failures preserve each established state/target boundary.
|
||||
- Source-boundary test proves `GameWindow` no longer constructs or loads a
|
||||
`SettingsStore`, owns persisted settings fields, or contains quality/display
|
||||
feature bodies.
|
||||
- Focused App tests, full App tests, Release solution build, and full Release
|
||||
suite.
|
||||
|
||||
## Review and commit gate
|
||||
|
||||
After the focused tests pass, the existing retail-conformance,
|
||||
architecture/integration, and adversarial reviewers inspect the complete diff
|
||||
in parallel. Confirmed findings are corrected and the same reviewers repeat
|
||||
until clean. Documentation, roadmap, issues, memory, `CLAUDE.md`, and
|
||||
`AGENTS.md` are reconciled in the same bisectable Checkpoint G commit.
|
||||
|
||||
## Implementation result
|
||||
|
||||
- `RuntimeSettingsController` is constructed before `Window.Create`; all five
|
||||
bags load once and `WindowOptions` consumes its immutable startup snapshot.
|
||||
- Startup substeps commit independently, so an audio failure retries audio
|
||||
without replaying already successful pacing/display/FOV work.
|
||||
- Late target binding is inert and reversible; expected-instance SettingsVM
|
||||
withdrawal cannot detach a replacement binding.
|
||||
- The concrete quality adapter preserves all five application steps and the
|
||||
unchanged MSAA restart boundary.
|
||||
- Combat changes merge their three fields into a live Gameplay draft rather
|
||||
than overwriting unrelated unsaved fields; successful external changes also
|
||||
advance the SettingsVM persisted baseline so Cancel cannot resurrect stale
|
||||
command state.
|
||||
- UI-lock target, persistence, and baseline publication form one explicit
|
||||
convergence state. A transient failure leaves the same requested value
|
||||
retryable instead of letting the equality fast-path strand unfinished work.
|
||||
- Enter/reset/toon changes load and publish Character state independently of
|
||||
developer-tool availability.
|
||||
- `GameWindow` no longer constructs or loads `SettingsStore`, owns persisted
|
||||
settings mirrors, or contains display/quality/settings feature bodies.
|
||||
- Candidate metrics: 3,663 raw lines / 162 fields / 37 methods, down 394 lines /
|
||||
36 fields / 17 methods from Checkpoint F and 12,060 lines (76.7%) from the
|
||||
campaign baseline.
|
||||
- Thirty-five focused App settings/boundary tests, all 43 `SettingsVMTests`, the
|
||||
UI Abstractions Release suite (534 pass), and the App Release suite (3,183
|
||||
pass / 3 intentional skips) pass. All three independent corrected-diff
|
||||
reviews are clean. The production App Release build has zero warnings/errors;
|
||||
the solution Release build retains the 17 existing #228 test-project warnings;
|
||||
and the complete Release suite passes 7,553 tests / 5 intentional skips.
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
# GameWindow Slice 8 — Checkpoint H Resource Ownership Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the remaining implicit, constructor-local, and nullable-field resource
|
||||
ownership in `GameWindow` with explicit single-owner transactions. This is a
|
||||
behavior-preserving lifetime cutover: draw/update order, portal presentation,
|
||||
retained UI behavior, settings behavior, and the frozen shutdown order do not
|
||||
change.
|
||||
|
||||
Checkpoint H does not move the complete shutdown manifest; that is Checkpoint
|
||||
J. It supplies the typed roots and retry semantics that J will consume.
|
||||
|
||||
## Frozen invariants
|
||||
|
||||
1. Every GL/kernel-owning object is published to exactly one owner immediately
|
||||
after successful construction.
|
||||
2. Renderers borrow the terrain atlas and dedicated sky shader. They never
|
||||
dispose those resources. The renderer borrower retires before the sole
|
||||
resource owner releases the borrowed atlas/shader.
|
||||
3. A leaf factory that throws before returning must release every GL name it
|
||||
created. Outer phase/lifetime rollback cannot recover a name that was never
|
||||
published.
|
||||
4. Bindless residency is a resource prefix: partial handle acquisition rolls
|
||||
back, successful non-residency is not replayed after a later failure, and
|
||||
textures are not deleted while a resident handle remains.
|
||||
5. Retained UI input is quiesced before the live-session barrier and physically
|
||||
detached afterward, independently of runtime/host disposal.
|
||||
6. Exactly one retained-UI ownership chain exists. Before runtime construction
|
||||
the lease owns the `UiHost`; after a runtime object is published the lease
|
||||
retains that exact partial/full runtime and delegates final disposal to it.
|
||||
Constructor or initialization failure never leaves a host in a local only.
|
||||
7. Update and render roots publish atomically as one frame graph. Silk callbacks
|
||||
resolve only through the slot; an empty/withdrawn slot is safely inert.
|
||||
8. Portal tunnel ownership starts in one fallback slot. Transfer clears the
|
||||
fallback only after the complete local-teleport controller factory returns;
|
||||
any earlier failure leaves the same tunnel reachable by shutdown.
|
||||
9. Successful disposal is never replayed. Failed disposal remains retryable.
|
||||
Terminal abandonment is explicit and retains references for native/process
|
||||
fallback rather than silently losing ownership.
|
||||
|
||||
## Typed owners
|
||||
|
||||
### `OwnedResourceSlot<T>` and `GameRenderResourceLifetime`
|
||||
|
||||
- `OwnedResourceSlot<T>` acquires through a factory, rejects duplicate
|
||||
publication, exposes a typed borrower, and clears ownership only after
|
||||
`Dispose` succeeds.
|
||||
- `GameRenderResourceLifetime` contains separate slots for `TerrainAtlas` and
|
||||
the dedicated sky `Shader`.
|
||||
- `GameWindow` acquires both through this owner. Shutdown releases the sky
|
||||
renderer and terrain renderer first, then the two resource slots, then GL.
|
||||
|
||||
### `RetailUiRuntimeLease`
|
||||
|
||||
- The lease exists before `OnLoad` and acquires the `UiHost` through a factory.
|
||||
- Runtime construction and initialization are separate. The runtime is stored
|
||||
in the lease before initialization begins.
|
||||
- Initialization failure attempts immediate cleanup. Successful cleanup
|
||||
rethrows the original initialization error; cleanup failure reports both and
|
||||
leaves the exact runtime in the lease for lifetime retry.
|
||||
- The lease exposes idempotent `QuiesceInput` and retryable `DeactivateInput`
|
||||
operations against the retained host.
|
||||
- If no runtime was constructed, lease disposal directly disposes the host. If
|
||||
a runtime exists, runtime disposal remains the one path that disposes the
|
||||
host. There are never two host disposers.
|
||||
- Explicit terminal abandonment is allowed only after a failed disposal pass;
|
||||
it makes later disposal inert while retaining the unresolved references for
|
||||
the native/process fallback handled by Checkpoint J.
|
||||
|
||||
### `GameFrameGraphSlot`
|
||||
|
||||
- `IGameUpdateFrameRoot` and `IGameRenderFrameRoot` are the narrow frame seams.
|
||||
- `UpdateFrameOrchestrator` and `RenderFrameOrchestrator` implement those seams.
|
||||
- `Publish(update, render)` is atomic and one-shot until withdrawal.
|
||||
- `Tick`/`Render` are inert before publication and after withdrawal.
|
||||
- Shutdown withdraws the pair only after live-session convergence and before
|
||||
any frame borrower retires.
|
||||
|
||||
### `TransferableResourceSlot<T>`
|
||||
|
||||
- `Acquire` owns one fallback resource.
|
||||
- `Transfer(factory)` retains fallback ownership while the complete destination
|
||||
factory runs and clears it only after that factory returns successfully.
|
||||
- Failed acquisition, resource preparation, destination construction, binding,
|
||||
and normal shutdown prefixes are covered without double disposal.
|
||||
|
||||
## Exception-safe GL leaves
|
||||
|
||||
### Shader program construction
|
||||
|
||||
`ShaderProgramConstruction` uses a narrow `IShaderProgramBuildApi`. It tracks
|
||||
the vertex shader, fragment shader, and linked program as soon as each name is
|
||||
returned. Compile, attach, link, detach, or cleanup failure attempts every
|
||||
remaining rollback operation. A clean rollback preserves the original failure;
|
||||
rollback failures are aggregated with it. `Shader.Dispose` becomes idempotent
|
||||
and retryable.
|
||||
|
||||
### Terrain atlas construction and residency
|
||||
|
||||
`GlTextureConstructionTransaction` tracks every texture name allocated by
|
||||
terrain, alpha, and fallback paths. `TerrainAtlas.Build`, `BuildAlphaAtlas`, and
|
||||
`BuildFallback` allocate only through that transaction. The transaction commits
|
||||
only after the complete `TerrainAtlas` object exists; failure rolls back all
|
||||
names in reverse acquisition order and reports cleanup failures with the
|
||||
original error.
|
||||
|
||||
`BindlessTexturePair` owns the terrain/alpha residency pair. Partial acquisition
|
||||
releases the first handle. Release attempts both handles, remembers successful
|
||||
substeps, and is retryable. `TerrainAtlas.Dispose` releases residency first,
|
||||
then independently deletes both texture names through the existing retryable
|
||||
shutdown transaction.
|
||||
|
||||
## Production integration order
|
||||
|
||||
1. Construct render-resource lifetime, UI lease, frame slot, and portal
|
||||
fallback slot before `Window.Create`/`OnLoad` acquisitions.
|
||||
2. Acquire the atlas through `GameRenderResourceLifetime`; construct
|
||||
`TerrainModernRenderer` as a borrower.
|
||||
3. Acquire `UiHost` through `RetailUiRuntimeLease`, wire input/assets, construct
|
||||
and publish `RetailUiRuntime` before initialization, then expose host/runtime
|
||||
only as borrower aliases.
|
||||
4. Acquire/prepare portal presentation in the fallback slot.
|
||||
5. Acquire the dedicated sky shader through the render-resource lifetime, then
|
||||
construct `SkyRenderer` as a borrower.
|
||||
6. Transfer the portal fallback only through the complete
|
||||
`LocalPlayerTeleportController` factory.
|
||||
7. Construct update/render roots as locals and publish them atomically through
|
||||
`GameFrameGraphSlot`.
|
||||
8. Update/render callbacks resolve only through the slot.
|
||||
9. Shutdown quiesces/deactivates UI through the lease, withdraws frame roots,
|
||||
disposes the UI lease and renderer borrowers, releases atlas/sky roots, then
|
||||
reaches GL.
|
||||
|
||||
## Automated gates
|
||||
|
||||
- owned-resource acquire/borrow/release, duplicate acquire, transient failure,
|
||||
retry, and no replay;
|
||||
- UI host acquisition, runtime constructor failure, Initialize failure with
|
||||
successful cleanup, transient cleanup then lifetime retry, persistent cleanup
|
||||
then explicit abandonment, separate input quiescence/deactivation, and exact
|
||||
host disposal count;
|
||||
- frame pair atomic publication, duplicate publication, update/render routing,
|
||||
withdrawal, pre-publication/post-withdrawal silence, and republish policy;
|
||||
- portal fallback acquisition/preparation/transfer failures plus successful
|
||||
transfer and every shutdown prefix, with exact one-resource disposal;
|
||||
- vertex/fragment compile failure, program creation/link failure, and cleanup
|
||||
failure after every created shader/program name;
|
||||
- terrain/alpha/fallback texture allocation and upload failure after every
|
||||
created name, reverse rollback, cleanup aggregation, and committed-name
|
||||
non-deletion;
|
||||
- bindless first/second acquisition failure, release partial failure/retry, and
|
||||
no deletion before residency release;
|
||||
- source boundary proves direct frame-root fields, constructor-local atlas/sky
|
||||
ownership, direct UI host/runtime ownership, and nullable portal fallback are
|
||||
removed from `GameWindow`;
|
||||
- focused App tests, production App Release build, solution Release build, and
|
||||
complete Release suite.
|
||||
|
||||
## Review and commit gate
|
||||
|
||||
The existing architecture/integration, retail-conformance, and adversarial
|
||||
reviewers inspect the complete tracked and untracked diff independently. Every
|
||||
confirmed finding is corrected and all three repeat until clean. Architecture,
|
||||
roadmap, milestones, issues, memory, `CLAUDE.md`, and `AGENTS.md` are reconciled
|
||||
in the same bisectable Checkpoint H commit.
|
||||
|
||||
## Implementation result — 2026-07-22
|
||||
|
||||
Checkpoint H landed the planned ownership graph without changing the accepted
|
||||
update/render order or gameplay behavior:
|
||||
|
||||
- `GameRenderResourceLifetime` owns the terrain atlas and dedicated sky shader;
|
||||
renderers borrow them and retire first.
|
||||
- `RetailUiRuntimeLease` owns Host/runtime construction, initialization, input
|
||||
cutoff, retryable disposal, and explicit terminal abandonment as one chain.
|
||||
- `GameFrameGraphSlot` publishes update/render roots atomically and makes empty
|
||||
or withdrawn callbacks inert.
|
||||
- `TransferableResourceSlot.AcquirePrepared` publishes the portal presentation
|
||||
before mesh preparation, retries the same partial resource, and refuses
|
||||
transfer until preparation has committed.
|
||||
- GL name creation records the returned name before post-command validation.
|
||||
Shader/program, texture, text-renderer, buffer/VAO, bindless-residency, and
|
||||
texture-binding operations advance ownership only after an always-on checked
|
||||
GL commit boundary.
|
||||
- Failed construction cleanup is retained by `GlConstructionCleanupLedger`;
|
||||
exact pending names and binding-restore obligations remain retryable, while
|
||||
successful cleanup stages never replay.
|
||||
|
||||
Four corrected-diff cycles closed issues found by the architecture, retail,
|
||||
and adversarial reviewers: partial bindless mutation, lost GL rollback names,
|
||||
constructor-local text resources, reentrant UI ownership, portal preparation,
|
||||
unchecked production GL calls, leaked texture binding, and the bindless post-
|
||||
commit verification gap. All three final reviews are clean.
|
||||
|
||||
Automated acceptance:
|
||||
|
||||
- focused Checkpoint H ownership/lifetime gate: 61 passed;
|
||||
- App Release suite: 3,236 passed / 3 intentional skips;
|
||||
- production App Release build: zero warnings and zero errors;
|
||||
- complete solution Release suite: 7,606 passed / 5 intentional skips; solution
|
||||
build has zero errors and the 17 existing test-project warnings tracked by
|
||||
#228;
|
||||
- `GameWindow.cs`: 3,689 raw lines / 162 fields / 37 methods, versus 15,723 /
|
||||
278 / 205 at the campaign baseline.
|
||||
|
||||
No AC-specific algorithm or intended retail behavior changed, so Checkpoint H
|
||||
adds no retail-divergence row. Checkpoint I is the next active unit.
|
||||
|
|
@ -0,0 +1,743 @@
|
|||
# GameWindow Slice 8 — composition and lifecycle shell
|
||||
|
||||
**Status:** Active 2026-07-22.
|
||||
**Parent program:** [`docs/architecture/code-structure.md`](../architecture/code-structure.md), Slice 8.
|
||||
**Baseline:** `96f8bfcf`; `GameWindow.cs` is 4,666 raw lines, 196 fields, and
|
||||
70 methods. The Release suite passes 7,341 tests / 5 fixture or environment
|
||||
skips. The connected lifecycle/reconnect and synchronized nine-stop soak gates
|
||||
pass.
|
||||
**Behavior rule:** This is the final behavior-preserving ownership slice. It
|
||||
must not change accepted input priority, session/reset behavior, update/render
|
||||
order, world presentation, quality settings, or retail gameplay behavior. It
|
||||
may fix resource and callback lifetime defects proven by this slice's ownership
|
||||
audit.
|
||||
|
||||
## Progress ledger
|
||||
|
||||
- [x] A — freeze construction/callback/shutdown order and delete proven dead or
|
||||
test-facade residue.
|
||||
- [x] B — make native-window callback intake an explicit reversible owner and
|
||||
define host-quiescence failure semantics.
|
||||
- [x] C — extract live-session host/reset/binding callbacks and verify the
|
||||
embedded skill formula against named retail.
|
||||
- [x] D — extract retail world-environment/day/weather behavior.
|
||||
- [x] E — extract two-phase raw pointer/camera input, focus, and framebuffer
|
||||
resize behind symmetric named subscriptions.
|
||||
- [x] F — extract the sole gameplay input-action router plus focused combat and
|
||||
diagnostic command owners.
|
||||
- [x] G — extract two-phase persisted settings/display/quality ownership.
|
||||
- [x] H — give terrain atlas, sky shader, retained `UiHost`, and both frame roots
|
||||
explicit single ownership and transfer seams.
|
||||
- [ ] I — group `OnLoad` into small ordered, fakeable composition phases with
|
||||
transactional partial-acquisition rollback.
|
||||
- [ ] J — move the exact retryable shutdown manifest to a focused lifetime
|
||||
owner and prove all partial-load/reentrant/retry paths.
|
||||
- [ ] K — in a separate #232 commit, add canonical owner snapshots to every soak
|
||||
checkpoint without weakening the process-memory guard.
|
||||
- [ ] L — corrected-diff reviews, focused and full Release gates, connected
|
||||
lifecycle/soak, framebuffer comparison, documentation, memory, and final
|
||||
visual handoff.
|
||||
|
||||
Each checked checkpoint lands as a bisectable commit. A checkpoint is not done
|
||||
while a new class delegates a substantial body back into `GameWindow`, stores
|
||||
`GameWindow`, or replaces 196 fields with one runtime/service-locator bag.
|
||||
|
||||
## 1. Outcome and non-goals
|
||||
|
||||
At slice exit `GameWindow` is the native construction shell:
|
||||
|
||||
```text
|
||||
GameWindow
|
||||
├── creates Window + GL + input context
|
||||
├── invokes ordered composition functions
|
||||
├── forwards tiny Silk callbacks to typed owners
|
||||
├── starts LiveSessionController last
|
||||
└── asks GameWindowLifetime to close synchronously
|
||||
```
|
||||
|
||||
The shell may retain injected Core/UI game-state roots whose replacement would
|
||||
be the separately deferred `GameEntity`/client-state aggregation migration. It
|
||||
must not retain AC algorithms, entity scans, session binding/reset bodies,
|
||||
settings algorithms, the input switch, anonymous device subscriptions, or the
|
||||
shutdown operation manifest.
|
||||
|
||||
This slice does **not**:
|
||||
|
||||
- alter the frozen twelve-phase update graph or ten-phase render graph;
|
||||
- port a new retail host loop or retire TS-53;
|
||||
- change input bindings, mouse feel, focus/reset semantics, camera formulas,
|
||||
resize/DPI behavior, frame pacing, VSync, MSAA, quality, draw distance, or
|
||||
resource budgets;
|
||||
- change live-session authority, packet parsing, reset order, the 35-second
|
||||
graceful F653 confirmation, reconnect, or transport teardown;
|
||||
- change UI layout, magic, portal presentation, selection, world rendering, or
|
||||
gameplay behavior;
|
||||
- fold full `GameEntity` aggregation, headless mode, Linux support, or GPU
|
||||
particle work into this campaign;
|
||||
- loosen the soak's working/private-memory threshold.
|
||||
|
||||
## 2. Frozen host behavior
|
||||
|
||||
No fresh retail research is required for the host boundaries. One audited
|
||||
exception exists: the embedded skill-formula helper currently cites ACE only,
|
||||
so Checkpoint C must complete the named-retail pseudocode/cross-reference/test
|
||||
workflow before moving or claiming that formula.
|
||||
|
||||
### 2.1 Startup and callbacks
|
||||
|
||||
Preserve this exact order:
|
||||
|
||||
1. before `Window.Create`, load persisted Display and resolve VSync/MSAA because
|
||||
these are native-context attributes;
|
||||
2. create the window and bind `DisplayFramePacingController` immediately;
|
||||
3. register Load, Update, main Render, pacing Render, Closing, FocusChanged,
|
||||
Move pacing, StateChanged pacing, and FramebufferResize in that order;
|
||||
4. in `OnLoad`, acquire GL and input from the native window;
|
||||
5. compose camera/DAT/effects/audio, persisted settings, optional devtools,
|
||||
world/render resources, retained UI, live presentation, streaming, session,
|
||||
teleport, then update/render roots;
|
||||
6. call `LiveSessionController.Start` last, including when live mode or
|
||||
credentials are absent, because its disabled path still performs the
|
||||
canonical reset.
|
||||
|
||||
Main Render remains registered before pacing so the wait occurs after all frame
|
||||
work and before Silk's swap. Direct raw MouseMove remains ordered after
|
||||
SilkKeyboard/MouseSource + InputDispatcher subscriptions and before retained
|
||||
`UiHost` device wiring.
|
||||
|
||||
### 2.2 Input, focus, and resize
|
||||
|
||||
Preserve the exact input-action priority:
|
||||
|
||||
1. pointer press/release transitions;
|
||||
2. scroll;
|
||||
3. combat press/release transitions;
|
||||
4. Press/DoubleClick gate;
|
||||
5. retained UI semantic actions;
|
||||
6. selection interactions;
|
||||
7. pressed movement/autorun;
|
||||
8. the remaining UI/debug/player/combat/escape commands.
|
||||
|
||||
Mouse capture remains three distinct operations:
|
||||
|
||||
- focus loss and camera-mode exit use lifecycle end and may publish a final
|
||||
movement update;
|
||||
- session reset uses reset semantics and cannot send into the ending session;
|
||||
- process close retires the session first, then releases presentation without
|
||||
an active session.
|
||||
|
||||
FramebufferResize continues to ignore non-positive sizes, then updates GL
|
||||
viewport, `ViewportAspectState`, camera aspect, and the forced devtools layout
|
||||
reset. It must not mutate retained `UiRoot` size; the next UI draw does that.
|
||||
|
||||
### 2.3 Session and reset
|
||||
|
||||
`LiveSessionController` remains the only session lifecycle owner. The extracted
|
||||
host adapter may hold typed binding/reset collaborators but no second session,
|
||||
generation, identity, GUID, routing, or command map.
|
||||
|
||||
Preserve `LiveSessionResetManifest` exactly: projection/capture/UI first;
|
||||
equipped projection before the canonical live runtime; live-runtime convergence
|
||||
before identity reset; pending effects/hooks/presentation last. The AC skill
|
||||
formula currently embedded in the window moves only after Checkpoint C adds its
|
||||
missing named-retail citation and conformance tests (or records the proven
|
||||
remaining adaptation).
|
||||
|
||||
### 2.4 Update and render
|
||||
|
||||
`OnUpdate` remains profiler scope plus one immutable
|
||||
`UpdateFrameOrchestrator.Tick`. `OnRender` takes the existing one window-size
|
||||
snapshot and calls one `RenderFrameOrchestrator.Render`. PView's
|
||||
`FramebufferSize` source remains distinct from the logical Window.Size used by
|
||||
the frame/UI/portal/screenshot path. TS-33 and TS-53 remain registered.
|
||||
|
||||
### 2.5 Shutdown
|
||||
|
||||
Closing synchronously completes the retryable transaction while the GL context
|
||||
is current. Direct `Dispose` is the constructed-never-run, Run-failure, and
|
||||
retry fallback. The native window is disposed only after successful completion
|
||||
or the explicitly reported last-resort incomplete fallback described below.
|
||||
|
||||
The frozen dependency order is:
|
||||
|
||||
1. set a no-throw host-quiescence gate and converge live-session shutdown as
|
||||
independent all-attempted **hard** operations; this guarantees F653 and
|
||||
transport teardown before dependents;
|
||||
2. attempt physical detachment of all window/Silk/UI ingress as explicitly
|
||||
reportable **soft** operations: retry them, record any persistent failure,
|
||||
but do not block now-safe owner/GPU teardown after quiescence;
|
||||
3. withdraw **both** update and render frame graphs through their shared slot;
|
||||
4. retire session/UI dependents;
|
||||
5. clear the live runtime while teardown callbacks remain alive;
|
||||
6. retire live dependents;
|
||||
7. wait for submitted GPU work;
|
||||
8. retire private/render frontends;
|
||||
9. retire shared textures;
|
||||
10. retire the mesh adapter;
|
||||
11. retire remaining render borrowers, including terrain renderer;
|
||||
12. retire separately owned borrowed assets, including terrain atlas and sky
|
||||
shader, only after their respective renderer stage converges;
|
||||
13. retire remaining render owners and frame-flight ownership;
|
||||
14. close DAT mappings;
|
||||
15. dispose the already-detached input context;
|
||||
16. dispose GL last;
|
||||
17. return to `GameWindow.Dispose`, which disposes the native window.
|
||||
|
||||
The logical quiescence gate makes callbacks inert even when a physical event
|
||||
unsubscribe reports failure. `ResourceShutdownTransaction` therefore gains an
|
||||
explicit non-blocking/reportable operation policy for physical detach only.
|
||||
Those failures remain in the final result and are never swallowed, but they do
|
||||
not strand unrelated resources. Session convergence and GPU drain remain hard
|
||||
barriers. Operations within a stage remain independent and all-attempted. Later
|
||||
stages remain protected until the current stage converges. Successful operations are
|
||||
never replayed on retry or reentrant completion.
|
||||
|
||||
Silk's `Closing` event is not cancellable. `OnClosing` therefore performs the
|
||||
first synchronous completion attempt and reports an incomplete transaction
|
||||
without pretending success. The outer `Dispose` retries the same retained
|
||||
transaction. If a persistent native/driver failure still prevents convergence,
|
||||
the native window/context is disposed as the explicit last-resort safety net
|
||||
and the incomplete result is logged with the blocked stage; it is never
|
||||
reported as a clean shutdown. Tests pin this fallback instead of assuming a
|
||||
close-cancellation mechanism Silk does not provide.
|
||||
|
||||
The lifetime state is explicit: `Active`, `RetryableIncomplete`, clean
|
||||
`Complete`, `CompleteWithCleanupFailures`, or terminal `AbandonedIncomplete`.
|
||||
Persistent soft-detach failure followed by otherwise complete teardown produces
|
||||
`CompleteWithCleanupFailures`; repeated Dispose is inert and returns the same
|
||||
immutable non-clean report. Hard-barrier retry is allowed only while the native
|
||||
context is retained. Last-resort native disposal moves to
|
||||
`AbandonedIncomplete`; every later Dispose is inert and reports the retained
|
||||
blocked-stage result without touching GL, callbacks, or the already-destroyed
|
||||
window.
|
||||
|
||||
## 3. Architecture and interfaces
|
||||
|
||||
### 3.1 Native window intake
|
||||
|
||||
Add a focused reversible binding equivalent to:
|
||||
|
||||
```csharp
|
||||
internal sealed class SilkWindowCallbackBinding : IDisposable
|
||||
{
|
||||
public static SilkWindowCallbackBinding Create(
|
||||
IWindow window,
|
||||
WindowCallbackTargets targets,
|
||||
DisplayFramePacingController pacing);
|
||||
|
||||
public void Attach();
|
||||
}
|
||||
```
|
||||
|
||||
`WindowCallbackTargets` is a fixed typed set for Load, Update, Render, Closing,
|
||||
FocusChanged, and FramebufferResize. It is not a general callback list. The
|
||||
binding is published into its lifetime slot after `Create` and before `Attach`
|
||||
begins, so even an attach failure plus rollback failure retains a cleanup owner.
|
||||
Attach treats the current edge as possibly acquired before calling a custom
|
||||
event add accessor and rolls back in reverse order if registration fails.
|
||||
The explicit lifecycle state monitor is released around every external event
|
||||
accessor; external cleanup waits for Attaching/Detaching to converge, while a
|
||||
gate-owned reentrant cleanup reports typed deferred completion instead of
|
||||
deadlocking or falsely succeeding. Dispose detaches in reverse registration
|
||||
order, joins concurrent callers, is idempotent/reentrant-safe, and makes later
|
||||
window events inert. `GameWindow` clears the retained slot only after terminal
|
||||
disposal. The separate pacing Render callback remains ordered after main Render.
|
||||
Every callback enters the shared no-throw host-quiescence monitor, so external
|
||||
shutdown drains an admitted callback, Closing can stop reentrantly, and a failed
|
||||
physical unsubscribe cannot re-enter a retired owner.
|
||||
|
||||
### 3.2 Input owners
|
||||
|
||||
Use two focused owners:
|
||||
|
||||
- `CameraPointerInputController` owns named raw MouseMove subscriptions,
|
||||
pointer position, camera-mode cursor transitions, focus loss, typed scroll
|
||||
operations, and the existing mode-specific sensitivities. Construction is
|
||||
deliberately two-phase: `AttachRaw` runs after dispatcher/source wiring and
|
||||
before retained `UiHost` wiring; `BindGameplayFrame` fills a focused deferred
|
||||
slot after `GameplayInputFrameController` exists. It consumes canonical
|
||||
`LocalPlayerModeState`, `ChaseCameraInputState`, input capture, and camera
|
||||
owners.
|
||||
- `GameplayInputActionRouter` is the **only** gameplay subscriber to
|
||||
`InputDispatcher.Fired` and owns the frozen priority graph above. Its pointer
|
||||
and scroll edges call typed operations on `CameraPointerInputController`; the
|
||||
pointer owner does not independently subscribe to `Fired`. The router calls existing typed
|
||||
retained-UI, selection, movement, player-mode, interaction, combat-command,
|
||||
and diagnostic-command owners.
|
||||
|
||||
The window binding, router Fired subscription, pointer raw MouseMove,
|
||||
Combat/Camera events, dispatcher-to-source links, source-to-Silk links, and
|
||||
retained-UI-to-Silk links must all be named and reversible. Shutdown's first
|
||||
stage deactivates them without disposing the input context or the UI owners
|
||||
needed by session reset. `UiHost` therefore exposes a separate idempotent input
|
||||
deactivation seam; its later full Dispose owns windows and rendering. Do not
|
||||
replace these edges with anonymous lambdas retained by a generic subscription
|
||||
bag.
|
||||
|
||||
Every wrapper on those edges—including Silk sources, dispatcher, raw pointer,
|
||||
retained UiHost, and optional devtools input—checks the same quiescence gate (or
|
||||
performs its no-throw logical deactivate in stage 1). No device callback can
|
||||
re-enter during the potentially 35-second live-session close while physical
|
||||
event removal waits for the following soft-detach stage.
|
||||
|
||||
Every multi-event Attach is transactional: if the Nth subscription fails, the
|
||||
already-attached prefix is removed in reverse order before the exception
|
||||
escapes. This applies to native window, dispatcher/source/Silk, raw pointer,
|
||||
Combat/Camera, retained `UiHost`, and optional devtools input bindings.
|
||||
|
||||
### 3.3 Session and world environment
|
||||
|
||||
- A focused App session host owns the current selection/entered/reset/binding
|
||||
factories around `LiveSessionLifecycleHost`, `LiveSessionEventRouter`, and
|
||||
`LiveSessionResetManifest`. It resolves the current session through
|
||||
`LiveSessionController`; it does not mirror it.
|
||||
- `WorldEnvironmentController` owns current loaded sky/day state,
|
||||
`AdminEnvirons`, provider refresh, WorldTime synchronization, and Weather
|
||||
changes. The existing retail day-group/weather algorithm moves verbatim.
|
||||
- A named skill-credit resolver owns the formula formerly nested in the session
|
||||
binding only after Checkpoint C verifies it against named retail; until then
|
||||
the current ACE-only interpretation is not overclaimed as retail-faithful.
|
||||
|
||||
### 3.4 Settings and diagnostics
|
||||
|
||||
`RuntimeSettingsController` owns SettingsStore, active toon key, persisted
|
||||
display/audio/gameplay/chat/character values, quality reapply, and UI
|
||||
lock/FPS/combat preferences. It is constructed before `Window.Create` and is
|
||||
the single source for one immutable startup snapshot and Display/VSync/MSAA.
|
||||
After GL/input sources/input dispatcher/camera/audio exist—but still before
|
||||
devtools, the world render dispatcher, terrain, retained UI, or streaming—it
|
||||
applies window/pacing/audio values and resolves the final quality snapshot.
|
||||
Later factories consume that resolved snapshot. A fixed
|
||||
typed `RuntimeSettingsTargets` implementation is late-bound only to support
|
||||
future runtime changes; binding it does not replay startup display or quality
|
||||
transitions. The controller exposes typed state/commands to existing consumers;
|
||||
it does not own those renderers, UI trees, or session state.
|
||||
|
||||
`RuntimeDiagnosticCommandController` owns command routing for collision wires,
|
||||
time/weather, sensitivity, and nearby-world dumps. State remains solely in its
|
||||
canonical owner: time/weather operations call `WorldEnvironmentController`,
|
||||
sensitivity calls `CameraPointerInputController.AdjustSensitivity`, and world
|
||||
queries use canonical runtime views. It creates no duplicate state or entity
|
||||
cache. Diagnostic algorithms do not remain in the window merely because their
|
||||
input actions originate there.
|
||||
|
||||
`FramebufferResizeController` is a separate GL-aware typed target. It owns the
|
||||
frozen viewport → `ViewportAspectState` → camera aspect → devtools layout-reset
|
||||
sequence and is not folded into the pointer owner.
|
||||
|
||||
### 3.5 Frame-root slots
|
||||
|
||||
A focused `GameFrameGraphSlot` is the sole publication point for the current
|
||||
`UpdateFrameOrchestrator` and `RenderFrameOrchestrator`. The two Silk stubs
|
||||
resolve through this slot; `GameWindowLifetime` withdraws it after session
|
||||
convergence and before borrowed owners retire. Clearing a lifetime snapshot is
|
||||
not withdrawal while a direct window field can still invoke the graph.
|
||||
|
||||
### 3.6 Ordered composition
|
||||
|
||||
`OnLoad` invokes the narrow production platform phase that owns
|
||||
`GL.GetApi(_window)` and `_window.CreateInput()`, then invokes small composition
|
||||
functions at these existing boundaries:
|
||||
|
||||
1. host input/camera;
|
||||
2. content/effects/audio;
|
||||
3. persisted settings/devtools;
|
||||
4. world/render resources;
|
||||
5. interactions/retained UI;
|
||||
6. live presentation/landblock publishers;
|
||||
7. streaming/session/local player/teleport;
|
||||
8. update/render roots;
|
||||
9. session start.
|
||||
|
||||
Each function receives only typed dependencies and may return a small immutable
|
||||
result needed by the next function. There is no stored mega-context. Stable
|
||||
owners are copied to locals before retained delegates are constructed so those
|
||||
delegates cannot capture `GameWindow` accidentally. Construction cycles use
|
||||
the existing focused deferred slots; they do not use callbacks into the window.
|
||||
|
||||
The production ordering lives in a fixed `GameWindowCompositionPipeline` with
|
||||
typed phase interfaces/factories. Tests invoke that same pipeline with fake
|
||||
platform/acquisition phases; no test-only clone or source-text assertion stands
|
||||
in for partial-load execution. A narrow production
|
||||
`GameWindowPlatformAcquisition` phase is invoked by `OnLoad` and alone calls
|
||||
`GL.GetApi(window)` and `window.CreateInput()` through injectable factories. It
|
||||
publishes GL to the lifetime before attempting input, then publishes input
|
||||
before later phases. Tests execute this exact method with fake factories,
|
||||
including GL success followed by input failure. Each concrete phase uses a
|
||||
transactional acquisition scope so failure after any internal acquisition
|
||||
unwinds the exact prefix.
|
||||
|
||||
Every acquired disposable is published to the lifetime owner immediately. A
|
||||
composer that cannot publish incrementally must unwind its own partial prefix
|
||||
before throwing. The portal-tunnel transfer remains explicit: publish fallback,
|
||||
prepare GL resources, transfer to `LocalPlayerTeleportController`, then clear
|
||||
the fallback before frame graphs are published.
|
||||
|
||||
Retained UI uses one `RetailUiRuntimeLease` retained by `GameWindowLifetime`
|
||||
from Host construction through final teardown. The lease initially owns
|
||||
`UiHost`, retains a partially constructed `RetailUiRuntime` before Initialize,
|
||||
and exposes the runtime as a borrower only after Mount succeeds. Constructor
|
||||
failure leaves Host in the lease; Initialize/cleanup failure leaves the exact
|
||||
partial runtime in the lease for later retry; success makes lease disposal call
|
||||
runtime disposal. There is no fallback-to-runtime ownership gap and never two
|
||||
independent host disposers.
|
||||
|
||||
### 3.7 Lifetime owner
|
||||
|
||||
`GameWindowLifetime` owns the one `ResourceShutdownTransaction` and typed
|
||||
teardown-only roots. It does not expose runtime service lookup or gameplay
|
||||
operations. It exists before `Window.Create`, so window/bootstrap acquisitions
|
||||
can publish their ownership immediately. Its transaction preserves §2.5 and
|
||||
fixes these audited gaps:
|
||||
|
||||
- update and render orchestrators are both withdrawn;
|
||||
- `TerrainAtlas` residency/textures are explicitly released after submitted
|
||||
work and before GL;
|
||||
- the dedicated sky `Shader` is explicitly disposed;
|
||||
- a published `UiHost` is disposed even if `RetailUiRuntime.Mount` fails;
|
||||
- window, dispatcher, pointer, combat, camera, and UI-root callbacks detach
|
||||
before the owners they target.
|
||||
|
||||
## 4. Detailed checkpoint execution
|
||||
|
||||
### A — freeze and prune
|
||||
|
||||
- Add structural freeze tests for the current startup/session-start, input,
|
||||
resize, update/render, and shutdown boundaries; later checkpoints replace
|
||||
these with functional owner tests rather than treating source checks as final
|
||||
acceptance.
|
||||
- Extend `ResourceShutdownTransactionTests` for multiple same-stage failures,
|
||||
failure then explicit retry, empty stages, and reentrant completion.
|
||||
- Delete only proven dead/test-facade residue: `_capturedMouse`, obsolete
|
||||
`_streamingRadius`, unused snap constants, `IsPlayerGuid`, `IsDoorName`, and
|
||||
test-only forwarding helpers. Tests call canonical owners directly.
|
||||
|
||||
Result: the exact native attributes/callbacks, input subscription priority,
|
||||
frame-root/session-start boundary, framebuffer behavior, shutdown stages, and
|
||||
native-window-last edge are frozen. Dead duplicate state and two test-only
|
||||
window facades are removed. Three corrected-diff reviews are clean; 56 focused
|
||||
tests and the complete App suite (2,991 pass / 3 intentional skips) pass.
|
||||
|
||||
### B — native window binding and host quiescence
|
||||
|
||||
- Implement exact attach/reverse-detach/rollback and the no-throw quiescence
|
||||
gate.
|
||||
- Replace direct `Run` event wiring with one owned binding while preserving the
|
||||
two Render handlers and their order.
|
||||
- Add post-detach silence, failure-after-Nth-attach, repeated/reentrant Dispose,
|
||||
callback-during-detach, and persistent physical-detach tests.
|
||||
|
||||
Result: one fixed typed binding now owns the exact nine Silk edges, including
|
||||
the two ordered Render callbacks. Create/publish/Attach and the explicit
|
||||
lifecycle state preserve cleanup ownership across partial event-accessor
|
||||
failure, rollback failure, concurrent or reentrant shutdown, and condition
|
||||
wakeups. The shared host gate drains admitted callbacks and makes failed
|
||||
physical detach logically inert. Three corrected-diff review loops are clean;
|
||||
54 focused tests, the App suite (3,027 pass / 3 intentional skips), and the full
|
||||
Release suite (7,386 pass / 5 intentional skips) pass. No connected gate was
|
||||
required because this checkpoint changes ownership only and preserves the
|
||||
frozen callback behavior.
|
||||
|
||||
### C — live-session host and skill formula
|
||||
|
||||
- Extract selection/entry/reset/binding factories around the existing canonical
|
||||
session, router, command, and reset owners.
|
||||
- Before moving the formula, write pseudocode for
|
||||
`SkillFormula::Calculate @ 0x00591960`, cross-check the DAT field semantics
|
||||
against ACE plus a second reference, then add conformance tests. If current
|
||||
behavior differs, register it rather than silently calling the ACE-only
|
||||
formula retail-faithful inside a structural commit.
|
||||
- Keep `LiveSessionController.Start` last; both disabled-live and
|
||||
missing-credential starts must execute the reset path.
|
||||
|
||||
Result: `LiveSessionHost` now owns reset-plan construction, exact selection and
|
||||
entered-world ordering, and create→attach route factories while resolving all
|
||||
session/command/in-world state through the sole `LiveSessionController`.
|
||||
Partially attached routes and every individual subscription edge retain failed
|
||||
cleanup work; successful edges are never replayed, and reset/new generations
|
||||
remain blocked until teardown converges. Named-retail research corrected the
|
||||
former ACE-only skill shortcut to exact unsigned `SkillFormula::Calculate @
|
||||
0x00591960` semantics. Three corrected-diff review loops are clean; focused
|
||||
session/formula/ledger tests, the App suite (3,048 pass / 3 intentional skips),
|
||||
Core.Net (548 pass), the Release build, and the full suite (7,408 pass / 5
|
||||
intentional skips) pass. No connected gate was required for this ownership
|
||||
checkpoint; the final connected lifecycle gate remains Checkpoint L.
|
||||
|
||||
### D — world environment
|
||||
|
||||
- Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`,
|
||||
WorldTime synchronization, provider swaps, and Weather changes verbatim into
|
||||
`WorldEnvironmentController`.
|
||||
- Preserve existing named-retail citations and registered sound adaptation;
|
||||
diagnostics call typed commands rather than owning time/weather state.
|
||||
|
||||
Result: `WorldEnvironmentController` is now the sole owner of the clock,
|
||||
loaded sky descriptor, selected day group, Weather state, server time sync,
|
||||
AdminEnvirons bridge, and time/weather debug cycles. `GameWindow` preserves its
|
||||
public readonly clock/weather aliases but only composes the owner into render
|
||||
and session seams. Initialization is explicitly one-shot, and missing GameTime
|
||||
restores the documented fallback origin instead of inheriting process-global
|
||||
state. Named-oracle review corrected the day picker to
|
||||
`SkyDesc::CalcPresentDayGroup @ 0x00500E10` and AdminEnvirons to
|
||||
`CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20`; TS-54/TS-55 now register
|
||||
the carried centered-audio and complete fog/ambient/radar gaps. Three
|
||||
corrected-diff reviews are clean; 17 focused tests, the App suite (3,059 pass /
|
||||
3 intentional skips), the warning-free Release build, and the full suite
|
||||
(7,419 pass / 5 intentional skips) pass. No connected gate was required for
|
||||
this behavior-preserving ownership checkpoint.
|
||||
|
||||
### E — pointer, focus, and framebuffer resize
|
||||
|
||||
- Make SilkKeyboard/Mouse sources and dispatcher links reversible, then attach
|
||||
the raw pointer owner in the existing pre-UiHost order.
|
||||
- Late-bind the gameplay-frame slot without resubscribing or reordering input.
|
||||
- Extract camera mode, lifecycle focus loss, scroll, and sensitivity state.
|
||||
- Extract the independent framebuffer resize target and pin logical Window.Size
|
||||
versus FramebufferSize behavior.
|
||||
- Require rollback after every Nth event add and silence between every shutdown
|
||||
stage for raw/retained/devtools device events.
|
||||
|
||||
Result: `CameraPointerInputController` now owns raw move, camera-mode cursor,
|
||||
focus-loss, scroll, and per-mode sensitivity policy; its gameplay-frame edge is
|
||||
late-bound without resubscription. Silk keyboard/mouse sources,
|
||||
`InputDispatcher`, retained UI bindings, and the devtools
|
||||
`QuiescentInputContext` are publish-before-attach, terminal after disposal,
|
||||
transactional on every side-effecting event add, and retry only the physical
|
||||
edges that remain pending. Mouse-only hosts retain camera/cursor behavior.
|
||||
`FramebufferResizeController` is the sole physical framebuffer publisher and
|
||||
seeds viewport/aspect/camera state from `FramebufferSize`, while logical UI and
|
||||
frame dimensions remain `Window.Size`. Shutdown now performs no-throw logical
|
||||
input cutoff, retires the live session, then removes physical callbacks; the
|
||||
post-session mouse-look release therefore cannot send a final movement packet,
|
||||
and a bad Silk remove accessor cannot block F653/transport teardown. TS-56
|
||||
records the carried non-retail camera input scalars. Three corrected-diff review
|
||||
loops are clean; 66 focused App tests, 9 dispatcher-lifetime tests, 5 Core input
|
||||
integration tests, the warning-free production Release build, and the complete
|
||||
Release suite (7,477 pass / 5 intentional skips) pass. `GameWindow` is 4,266
|
||||
raw lines / 194 fields / 65 methods. No connected gate was required for this
|
||||
behavior-preserving ownership checkpoint; Checkpoint L retains the final
|
||||
connected lifecycle and visual gates.
|
||||
|
||||
### F — gameplay action and command routing
|
||||
|
||||
- Move the frozen action-priority graph into the sole Fired subscriber.
|
||||
- Extract combat mode command behavior and diagnostic command routing to typed
|
||||
owners; retained UI, selection, movement, player mode, and item interaction
|
||||
remain canonical.
|
||||
- Make Combat, Camera, dispatcher, and retained-root subscriptions named,
|
||||
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
|
||||
|
||||
- Construct `RuntimeSettingsController` before Window.Create and source startup
|
||||
Display/VSync/MSAA from it.
|
||||
- After GL/camera/audio and before devtools/world, apply startup
|
||||
display/pacing/audio and resolve the immutable quality snapshot consumed by
|
||||
downstream factories.
|
||||
- Late-bind typed runtime targets without replaying startup transitions, then
|
||||
move runtime display/quality changes, frame-rate/UI-lock/combat preferences,
|
||||
and toon-scoped state.
|
||||
- Preserve restart-required quality behavior and test transitions/previews
|
||||
without requiring GL.
|
||||
|
||||
**Implementation result:** one pre-window `RuntimeSettingsController` now owns
|
||||
the concrete settings store, the immutable startup snapshot, current Display /
|
||||
Audio / Gameplay / Chat / Character values, the active toon, resolved quality,
|
||||
and the optional Settings draft. Startup applies pacing, window state, saved
|
||||
FOV, and audio in order exactly once; a failed later substep retries without
|
||||
replaying a successful earlier substep. Complete runtime targets bind only
|
||||
after their borrowers exist and binding performs no replay. Display saves keep
|
||||
the persistence → live window → snapshot → quality order; quality applies
|
||||
alpha-to-coverage → anisotropy → render range → streaming → completion budget.
|
||||
Combat preference changes merge only their three fields into a live draft, so
|
||||
unrelated unsaved Gameplay edits survive. Saved FOV now applies without
|
||||
devtools, correcting the old accidental SettingsVM gate. `/framerate` retains
|
||||
the named-retail live toggle/notice and keeps acdream's shipped cross-launch
|
||||
persistence as documented AP-121. UI-lock convergence requires target,
|
||||
persistence, and Settings baseline publication; a failed substep keeps the
|
||||
same-value command retryable. `GameWindow` is 3,663 raw lines / 162 fields / 37
|
||||
methods. Thirty-five focused App settings/boundary tests, all 43
|
||||
`SettingsVMTests`, the UI Abstractions Release suite (534 pass), and the App
|
||||
Release suite (3,183 pass / 3 intentional skips) pass. All three independent
|
||||
corrected-diff reviews are clean; the production App Release build has zero
|
||||
warnings/errors, and the complete Release suite passes 7,553 tests / 5
|
||||
intentional skips (17 existing test-project warnings remain tracked by #228).
|
||||
|
||||
### H — explicit single resource ownership and frame slots
|
||||
|
||||
- Publish TerrainAtlas and the dedicated sky Shader as separate lifetime roots;
|
||||
renderers remain borrowers and retire before the sole atlas/shader disposal.
|
||||
- Publish `RetailUiRuntimeLease` before Host/Mount acquisition; add separate
|
||||
idempotent input deactivation and test constructor failure, Initialize failure
|
||||
with successful cleanup, transient cleanup followed by lifetime retry,
|
||||
persistent cleanup followed by terminal abandonment, and exactly one Host
|
||||
disposal/no unowned Host on every path.
|
||||
- Publish update/render roots only through `GameFrameGraphSlot` and withdraw the
|
||||
slot after session convergence.
|
||||
- Pin portal fallback/transfer unchanged and test every normal/partial prefix
|
||||
for no leak or double delete.
|
||||
- Make `Shader` construction and `TerrainAtlas.Build` internally exception-safe;
|
||||
phase scopes cannot recover GL names created inside a factory that throws.
|
||||
Inject GL compile/link/texture-allocation failure after every internal name
|
||||
and prove exact rollback/residency release.
|
||||
|
||||
Checkpoint H is complete. `GameRenderResourceLifetime` is the sole terrain-
|
||||
atlas and dedicated-sky-shader owner; renderers borrow and retire before those
|
||||
roots. `RetailUiRuntimeLease` retains the Host/runtime chain through partial
|
||||
construction, input cutoff, retryable disposal, and explicit terminal
|
||||
abandonment. `GameFrameGraphSlot` publishes update/render roots atomically, and
|
||||
the portal tunnel now uses one prepare-aware fallback/transfer transaction.
|
||||
Shader, terrain, text-renderer, buffer/VAO, bindless, and texture-binding
|
||||
construction/mutation paths use always-on checked GL commit boundaries. A
|
||||
failed cleanup retains the exact name/handle/binding obligation for retry, and
|
||||
successful substeps never replay. `GameWindow` is 3,689 raw lines / 162 fields
|
||||
/ 37 methods. The focused ownership gate passes 61 tests, the App Release suite
|
||||
passes 3,236 tests / 3 intentional skips, and all three independent final
|
||||
corrected-diff reviews are clean. The complete Release suite passes 7,606 tests
|
||||
/ 5 intentional skips; the 17 existing test-project warnings remain tracked by
|
||||
#228.
|
||||
|
||||
### I — ordered production composition
|
||||
|
||||
- Split the 2,289-line `OnLoad` body at §3.6 boundaries using the fixed typed
|
||||
production pipeline, small immutable phase results, and no stored mega-context.
|
||||
- Remove retained GameWindow captures via stable locals, typed adapters, and
|
||||
existing deferred slots.
|
||||
- Run the same pipeline with fake acquisition phases and inject failure after
|
||||
platform, GL, input, DAT/effects, render resources, retained UI, live/session,
|
||||
teleport transfer, and frame-root publication. Each concrete phase also
|
||||
proves transactional rollback of its internal acquisition prefix.
|
||||
|
||||
### J — lifetime cutover and shutdown failure policy
|
||||
|
||||
- Move the teardown-only stage records and shutdown manifest out of GameWindow.
|
||||
- Co-stage no-throw quiescence and live-session convergence as hard operations;
|
||||
retry/report physical detach as non-blocking operations, withdraw both frame
|
||||
graphs next, then preserve the remaining frozen order.
|
||||
- Cover constructed-never-run, Load-never-fired, normal WM_CLOSE, WM_CLOSE then
|
||||
Dispose, direct/repeated/reentrant Dispose, transient/persistent failures,
|
||||
session deferred disposal, optional-devtools absence, GPU-drain failure, and
|
||||
every partial-load prefix.
|
||||
- Verify acquired owners dispose exactly once, unacquired owners never dispose,
|
||||
completed operations never replay, and native-window fallback is last and
|
||||
explicitly reported incomplete when a non-cancellable close cannot converge.
|
||||
- Verify persistent physical-detach failure still retires downstream owners and
|
||||
is reported, while persistent session/GPU barriers still protect dependents;
|
||||
after native fallback the terminal abandoned state makes repeated Dispose
|
||||
inert.
|
||||
|
||||
### K — canonical soak checkpoints (#232, separate commit)
|
||||
|
||||
- Extract `WorldLifecycleResourceSnapshotSource` from the window and reuse the
|
||||
existing render diagnostics source where fields overlap.
|
||||
- Change `checkpoint <name>` from an immediate retained-UI-time capture to a
|
||||
FIFO request with an acknowledgement token. The script runner treats that
|
||||
token as a command barrier: it executes no later command until the token is
|
||||
resolved. After private presentation returns and
|
||||
`RenderFrameDiagnosticsController.Publish` has published the current frame,
|
||||
a post-diagnostics checkpoint phase drains requests using that same immutable
|
||||
`RenderFrameOutcome` plus one sample from every canonical source. This avoids
|
||||
previous-frame diagnostics mixed with current-frame owner counts.
|
||||
- Mark the token Succeeded only after serialization/write completes. Deferred
|
||||
capture/write failure marks it Failed, and the runner stops with that exact
|
||||
error on its next tick. Preserve request sequence/name across the deferred
|
||||
edge, drain independent producers in FIFO order, and cancel pending tokens
|
||||
with an explicit shutdown result.
|
||||
- Store one token against the active script-command index. Repeated pending
|
||||
ticks poll that token and never enqueue again—even when a render failed before
|
||||
the post-diagnostics drain. Clear it only after Success, Failure, or
|
||||
Cancellation is consumed; one command therefore produces one request, write,
|
||||
and sequence increment.
|
||||
- Add exactly nine ordered named `checkpoint` commands, parse the JSONL into the
|
||||
soak report, and assert checkpoint identity/order.
|
||||
- Gate pending teardown, retirement, staging, and warmup at zero; compare exact
|
||||
canonical owner/cache counts and bytes at Caul return → Caul plateau; label
|
||||
authoritative entity/animation population changes as workload warnings.
|
||||
- Preserve process working/private memory as a separately labelled secondary
|
||||
residency guard with unchanged thresholds. Require two fresh-process clean
|
||||
route runs before closing #232.
|
||||
|
||||
### L — final closeout
|
||||
|
||||
- Run three independent corrected-diff reviews: retail conformance,
|
||||
architecture/integration, and adversarial lifecycle/failure analysis.
|
||||
- Resolve every confirmed finding and re-review until clean.
|
||||
- Run focused App/Core/UI tests, Release build, and the complete Release suite.
|
||||
- Run connected lifecycle/reconnect and two fresh-process synchronized
|
||||
nine-stop soaks; compare stable framebuffer checkpoints with Slice 7.
|
||||
- Audit every active divergence row that still points at `GameWindow`; retarget
|
||||
ownership paths only. Keep TS-53 and other unresolved mechanisms open.
|
||||
- Update architecture, milestones, roadmap, issues, AGENTS/CLAUDE, and durable
|
||||
memory with final metrics and exact gates.
|
||||
|
||||
## 5. Automated acceptance
|
||||
|
||||
- exact window callback attach order, reverse detach, rollback, reentrancy, and
|
||||
post-detach silence;
|
||||
- transactional rollback after every Nth add for all multi-event bindings, plus
|
||||
retained/devtools/raw device silence between every shutdown stage;
|
||||
- exact input subscription/priority order and focus/reset/close semantics;
|
||||
- exact resize order and non-positive-size rejection;
|
||||
- disabled-live and missing-credentials startup still perform reset; live
|
||||
startup remains last;
|
||||
- PView still reads FramebufferSize while RenderFrameInput, retained UI,
|
||||
portal/private presentation, and screenshots share one Window.Size snapshot;
|
||||
- exact session reset trace and current-session borrowing;
|
||||
- partial-load rollback at every composition checkpoint;
|
||||
- TerrainAtlas, sky Shader, UiHost, frame graphs, GPU-flight, DAT, input, GL,
|
||||
and native window have explicit tested lifetimes; terrain/sky renderers remain
|
||||
borrowers and cannot also dispose the separately owned atlas/shader;
|
||||
- throwing Shader/TerrainAtlas leaf factories release every internal GL name
|
||||
and bindless-residency prefix;
|
||||
- shutdown retry/all-attempted/no-replay semantics remain intact;
|
||||
- persistent non-cancellable close fallback names the blocked stage and never
|
||||
reports a clean shutdown;
|
||||
- completed teardown with soft-detach failures reports immutable
|
||||
`CompleteWithCleanupFailures`, and repeated Dispose is inert;
|
||||
- no anonymous MouseMove subscription and no substantial Silk callback body;
|
||||
- no stored `GameWindow`, broad service locator, or feature-owner callback
|
||||
facade into the window; the fixed native `WindowCallbackTargets` binding is
|
||||
the intentional host-boundary exception;
|
||||
- `OnLoad` is ordered composition, `OnUpdate`/`OnRender` remain one handoff,
|
||||
and `OnClosing`/`Dispose` delegate to one lifetime owner;
|
||||
- all canonical soak snapshots are post-diagnostics same-frame atomic,
|
||||
round-trip, preserve request/name order across the deferred edge and nine-name
|
||||
order across two fresh processes, and name the exact owner/property on growth;
|
||||
- checkpoint acknowledgement blocks following script commands, propagates
|
||||
deferred write failure, and reports shutdown cancellation;
|
||||
- repeated pending ticks enqueue exactly once and cannot duplicate a checkpoint
|
||||
after a failed/delayed render;
|
||||
- complete Release suite, lifecycle gate, soak gate, and framebuffer comparison
|
||||
pass.
|
||||
|
||||
## 6. Final visual handoff
|
||||
|
||||
The only user pause is one connected visual smoke gate after all automation is
|
||||
green:
|
||||
|
||||
- first login world bootstrap and radar;
|
||||
- movement, mouse look/orbit, resize, focus loss/return, combat toggle;
|
||||
- inventory/skills/spellbook shared panel and retained UI input;
|
||||
- outdoor, building, dungeon, portal/recall, paperdoll, particles, alpha;
|
||||
- graceful close and fresh-process reconnect.
|
||||
|
||||
After that pass the GameWindow structural campaign is complete. The roadmap
|
||||
returns to the carried M3 magic/spell-bar/spellbook/component-book and final
|
||||
two-client portal-out/materialization visual gates before new M4 feature work.
|
||||
|
|
@ -4,11 +4,16 @@
|
|||
**Status:** RESEARCH COMPLETE — algorithm recovered, ready for C# port.
|
||||
**Companion docs:** `2026-04-23-sky-retail-verbatim.md` (full sky pipeline map).
|
||||
|
||||
> **Named-oracle correction (2026-07-22):** the Sept 2013 PDB identifies this
|
||||
> body as `SkyDesc::CalcPresentDayGroup @ 0x00500E10`. References below to
|
||||
> `FUN_00501990 @ 0x00501990` describe the older fallback binary/chunk only;
|
||||
> that address is inside `SkyDesc::Pack` in the named 2013 build.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The algorithm lives in `FUN_00501990` (previously mis-labeled "deterministic PES roll" in the sky-verbatim doc; it is actually **SkyDesc::PickCurrentDayGroup**).
|
||||
Retail's DayGroup picker is a **simple integer LCG mixed with `(YEAR, DAY_OF_YEAR)` from the live `TimeOfDay` struct**, scaled by `DayGroupCount`. It is **not** a weighted walk over `ChanceOfOccur` — it's uniform over all DayGroups. Dereth's 20 DayGroups all carry `ChanceOfOccur = 5.0f` anyway, so uniform matches the intent. The named algorithm is **`SkyDesc::CalcPresentDayGroup @ 0x00500E10`**; `FUN_00501990` is its older-build fallback identity.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -41,7 +46,7 @@ Note: `FUN_004ff4b0` (the OTHER mapped trampoline → `FUN_00502a10`) is called
|
|||
|
||||
```c
|
||||
// chunk_00500000.c:1274 FUN_00501990 @ 0x00501990 size=138
|
||||
// SkyDesc::PickCurrentDayGroup(this)
|
||||
// Older build of SkyDesc::CalcPresentDayGroup(this)
|
||||
void __fastcall FUN_00501990(uint *param_1)
|
||||
{
|
||||
float fVar1;
|
||||
|
|
@ -127,7 +132,7 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay::
|
|||
| 0x00 | double | `EpochBase` (dat-declared) | `FUN_005a7fd0:6045` |
|
||||
| 0x08 | int | `BaseYear` (dat-declared) | `FUN_005a7fd0:6051` |
|
||||
| 0x0C | float | `SecondsPerDay` | `FUN_005a7fd0:6056` |
|
||||
| **0x10** | int | **`SecondsPerDay` (int copy — source of `iVar6`)** | `FUN_005a7fd0:6061` |
|
||||
| **0x10** | int | **`DaysPerYear` (source of `iVar6`; live-probed as 360)** | `FUN_005a7fd0:6061`; `2026-04-23-retail-memory-probe.md` |
|
||||
| 0x40 | double | `SecondsPerYear = DaysPerYear × SecondsPerDay` | `FUN_005a7fd0:6068` |
|
||||
| 0x48 | float | `DayFraction` (0..1) | `FUN_005a7800:5497` |
|
||||
| 0x50 | double | CurrentDay startTick | `FUN_005a7510` (via `FUN_005a75b0`) |
|
||||
|
|
@ -136,24 +141,28 @@ Global: `DAT_008ee9c8` (type `void *`). Populated by `FUN_005a7fd0` (TimeOfDay::
|
|||
| **0x68** | int | **`DayOfYear`** | `FUN_005a7510:5304`: `= floor(withinYearSec / secsPerDay)` |
|
||||
| 0x6c | int | `SeasonIndex` | `FUN_005a7510:5313` |
|
||||
|
||||
The `iVar6 = *(int *)(DAT_008ee9c8 + 0x10)` read in `FUN_00501990` picks up `SecondsPerDay` as a multiplier — acting as a spread-factor to guarantee different days in different years yield different hash inputs. (If both sides of the multiplication were small — Year in [0, ~200], DayOfYear in [0, 365] — and `SecondsPerDay` were 1, the seed range would be tiny. Using the full `SecondsPerDay` integer makes the seed uniform over a ~31-bit range before the LCG step.)
|
||||
The `days_per_year = GameTime::current_game_time->days_per_year` read in
|
||||
`SkyDesc::CalcPresentDayGroup` is the year-to-day multiplier. The live memory
|
||||
probe measured 360 at the older build's corresponding `TimeOfDay+0x10` field,
|
||||
so `Year × DaysPerYear + DayOfYear` is a flat total-day index.
|
||||
|
||||
Retail Dereth values (from dat — confirm when we next parse a Region): `SecondsPerDay = 1800` (30 real-minutes = one Dereth-day per r12 §11, times some scale factor baked into the dat). Any value works — the algorithm is agnostic.
|
||||
Retail Dereth's DAT-backed `DaysPerYear` value is 360.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pseudocode (ready for C# port)
|
||||
|
||||
```csharp
|
||||
// SkyDesc.PickCurrentDayGroup — ports acclient.exe FUN_00501990 @ 0x00501990
|
||||
// SkyDesc.CalcPresentDayGroup — named retail @ 0x00500E10
|
||||
// Older-build cross-reference: FUN_00501990 @ 0x00501990
|
||||
// Decompile: docs/research/decompiled/chunk_00500000.c:1276
|
||||
// Must be called every frame before SkyDesc.UpdateSkyObjectTable; value is
|
||||
// stable across frames within one Dereth-day so repeated calls are cheap.
|
||||
public static int PickCurrentDayGroup(int year, int secondsPerDay, int dayOfYear, int dayGroupCount)
|
||||
public static int CalcPresentDayGroup(int year, int daysPerYear, int dayOfYear, int dayGroupCount)
|
||||
{
|
||||
// Step 1: deterministic per-day seed.
|
||||
// (This mirrors retail's 3-int read from TimeOfDay+0x64/0x10/0x68.)
|
||||
int seed = year * secondsPerDay + dayOfYear;
|
||||
int seed = year * daysPerYear + dayOfYear;
|
||||
|
||||
// Step 2: 32-bit signed LCG (x86 wraps silently; force it in C#).
|
||||
int mixed = unchecked(seed * 0x6A42FDB2 + (int)0x8ABE1652);
|
||||
|
|
@ -180,14 +189,15 @@ Call from the per-frame sky render hook, **before** the keyframe-bracket interpo
|
|||
|
||||
```csharp
|
||||
// once per frame, before sky object/light interp
|
||||
skyDesc.CurrentDayGroupIndex = SkyDesc.PickCurrentDayGroup(
|
||||
skyDesc.CurrentDayGroupIndex = SkyDesc.CalcPresentDayGroup(
|
||||
year: world.TimeOfDay.Year,
|
||||
secondsPerDay: world.TimeOfDay.SecondsPerDayInt, // the int copy at TimeOfDay+0x10
|
||||
daysPerYear: world.TimeOfDay.DaysPerYear,
|
||||
dayOfYear: world.TimeOfDay.DayOfYear,
|
||||
dayGroupCount: skyDesc.DayGroups.Count);
|
||||
```
|
||||
|
||||
If ACE and acdream agree on `(Year, SecondsPerDay, DayOfYear)` (trivially true — ACE computes these server-side from the same epoch), both clients will converge to the same index every Dereth-day. This replaces the current SplitMix64 path.
|
||||
Retail and acdream converge whenever their `(Year, DaysPerYear, DayOfYear)`
|
||||
inputs agree. ACE does not select a day group; each client computes it locally.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,12 +214,10 @@ So retail's decompile IS our only source, and we have it.
|
|||
|
||||
## 7. Gaps
|
||||
|
||||
None critical. Two minor open items:
|
||||
None critical. One minor open item:
|
||||
|
||||
1. **`_DAT_007c6f10` exact bit value.** I inferred `1.0/2^32` from usage pattern but did not find a data-section dump that prints the literal. If the live client ever shows off-by-one drift on a boundary day, re-verify by dumping the .rdata section at 0x007c6f10. (ACDREAM_DUMP_SKY already logs picked index + inputs; we can correlate against a retail client session to confirm.)
|
||||
|
||||
2. **`TimeOfDay+0x10` value for live Dereth.** Retail `SecondsPerDay` has been quoted as both 1800 and 3600 in various reverse-engineering docs depending on whether "seconds" means realtime seconds or Dereth-clock seconds. The algorithm itself is insensitive — whatever integer the dat provides is what both retail and acdream must use. If our Region parser stores it in a differently-named field, make sure the call site passes the dat-raw value (the int at TimeOfDay+0x10), not a re-derived one.
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary table
|
||||
|
|
@ -217,8 +225,8 @@ None critical. Two minor open items:
|
|||
| Question | Answer | Evidence |
|
||||
|---|---|---|
|
||||
| Caller of `FUN_00502a10` | `FUN_004ff4b0` → called from `FUN_00508010:7560` | `chunk_004F0000.c:10732`, `chunk_00500000.c:7560` |
|
||||
| DayGroup selection function | `FUN_00501990` | `chunk_00500000.c:1276` |
|
||||
| Hash formula | `(Year × SecondsPerDay + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `chunk_00500000.c:1296` |
|
||||
| DayGroup selection function | `SkyDesc::CalcPresentDayGroup @ 0x00500E10` | named Sept 2013 PDB/pseudo-C; older fallback `FUN_00501990`, `chunk_00500000.c:1276` |
|
||||
| Hash formula | `(Year × DaysPerYear + DayOfYear) × 0x6A42FDB2 + 0x8ABE1652` | `SkyDesc::CalcPresentDayGroup @ 0x00500E10`; older `chunk_00500000.c:1296` |
|
||||
| Weighted by ChanceOfOccur? | **No.** Uniform over DayGroupCount. | No CDF loop in FUN_00501990 |
|
||||
| Selected index stored at | `SkyDesc + 0x00` (via `*param_1` write) | `chunk_00500000.c:1307,1309` |
|
||||
| Read back by | `FUN_00502a10:2429`, `FUN_00501600`, `FUN_00501860` | `chunk_00500000.c:2429` |
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ DayOfYear = 47 ✓
|
|||
The decompile agent's C trail
|
||||
(`docs/research/2026-04-23-sky-decompile-hunt-C.md` §1 + §4) labeled
|
||||
`TimeOfDay+0x10` as *"SecondsPerDay (int copy — source of iVar6)"*
|
||||
for the `FUN_00501990` (`SkyDesc::PickCurrentDayGroup`) LCG seed.
|
||||
for the named `SkyDesc::CalcPresentDayGroup @ 0x00500E10` LCG seed
|
||||
(older-build cross-reference `FUN_00501990 @ 0x00501990`).
|
||||
|
||||
The live probe disproves that. The value is **360** — the same as
|
||||
`GameTime.DaysPerYear` from the dat.
|
||||
|
|
@ -43,7 +44,8 @@ The live probe disproves that. The value is **360** — the same as
|
|||
Implication for the retail LCG seed formula:
|
||||
|
||||
```c
|
||||
// FUN_00501990 line 1296 of chunk_00500000.c:
|
||||
// SkyDesc::CalcPresentDayGroup @ 0x00500E10
|
||||
// (older build: FUN_00501990 line 1296 of chunk_00500000.c):
|
||||
iVar4 = (iVar3 * iVar6 + iVar4) * 0x6a42fdb2 + -0x7541e9ae;
|
||||
// ^Year ^x0x10 ^DayOfYear
|
||||
```
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
|
|||
| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | – | B | P+W | P+W | Fog presets / sound cues |
|
||||
| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
|
||||
| 0xF643 | inbound | CharacterCreateResponse | – | PB | B | – | –defer:char-creation | Char-creation flow not yet built |
|
||||
| 0xF653 | outbound | CharacterLogOff | – | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
|
||||
| 0xF653 | both | CharacterLogOff | – | PB | P | B | PB+W | Request carries active character GUID; opcode-only server confirmation gates transport disconnect |
|
||||
| 0xF655 | both | CharacterDelete | – | PB | P | – | –defer:char-mgmt | Char-management UI deferred |
|
||||
| 0xF656 | outbound | CharacterCreate | – | PB | P | – | –defer:char-creation | Char-creation flow not yet built |
|
||||
| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
## Sources
|
||||
|
||||
- `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`
|
||||
- `ClientCombatSystem::SetCombatMode @ 0x0056BE30`
|
||||
- `ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0`
|
||||
- `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00`
|
||||
- `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0`
|
||||
- `SmartBox::HandleParentEvent @ 0x004535D0`
|
||||
|
|
@ -52,10 +54,28 @@ GetDefaultCombatMode(showError):
|
|||
return Magic
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
`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
|
||||
returns the first entry whose location intersects the requested mask (and whose
|
||||
priority intersects the requested priority when that priority is non-zero).
|
||||
|
|
|
|||
|
|
@ -204,6 +204,13 @@ on ParentEvent(parent, child):
|
|||
strictly advance the child's Position timestamp
|
||||
apply parent + placement without creating a second position counter
|
||||
|
||||
on CreateObject with Parent but no AnimationFrame field:
|
||||
PhysicsDesc construction initializes animframe_id to 0
|
||||
PhysicsDesc::UnPack explicitly leaves animframe_id at 0 when absent
|
||||
set_description installs placement 0 before the parent relation is realized
|
||||
therefore treat absent wire AnimationFrame as placement 0, not as a
|
||||
missing relation
|
||||
|
||||
on PickupEvent(object):
|
||||
require the exact live Instance
|
||||
strictly advance the shared Position timestamp
|
||||
|
|
|
|||
|
|
@ -194,6 +194,29 @@ InterpolationManager.adjust_offset(delta, dt):
|
|||
relative.frame.set_heading(0)
|
||||
delta = relative.frame // replaces both origin and orientation
|
||||
|
||||
## Part-count retention (`CPartArray::UpdateParts` @ `0x005190F0`)
|
||||
|
||||
```text
|
||||
count = min(partArray.num_parts, animationFrame.num_parts)
|
||||
for i in 0 .. count - 1:
|
||||
part[i].frame = combine_scaled(animationFrame.part[i], partArray.scale)
|
||||
|
||||
// No loop touches [count .. partArray.num_parts).
|
||||
// Those CPhysicsPart frames therefore retain their complete prior state.
|
||||
```
|
||||
|
||||
The retained state has two presentation channels in acdream:
|
||||
|
||||
- the visible mesh transform retains Setup `DefaultScale`, animated frame, and
|
||||
object `ObjScale`;
|
||||
- the indexed rigid/effect pose retains animated orientation and the
|
||||
ObjScale-adjusted origin, without Setup `DefaultScale`.
|
||||
|
||||
On the first presentation, "prior" means the already-hydrated Setup/rest
|
||||
frames installed by PartArray construction. A short later AnimFrame retains
|
||||
the most recently presented transform in both channels. Resetting missing
|
||||
parts to origin/identity is not retail behavior.
|
||||
|
||||
CPhysicsObj.SetPositionInternal(transition):
|
||||
if transition.current_cell is null:
|
||||
prepare_to_leave_visibility()
|
||||
|
|
|
|||
|
|
@ -8,18 +8,23 @@ Retail oracle: Asheron's Call v11.4186 (September 2013 EoR)
|
|||
This note pins the control-flow order needed by R6. It deliberately separates
|
||||
two kinds of animation-hook work:
|
||||
|
||||
- **control hooks** such as `AnimDoneHook`, whose result changes the motion
|
||||
- the immediate **`AnimDoneHook` control path**, whose result changes the motion
|
||||
queues observed later in the same object update; and
|
||||
- **pose-dependent presentation hooks** such as particles and sounds, whose
|
||||
App-layer delivery may wait until the final root/part pose has been
|
||||
published without changing motion state.
|
||||
- **all other captured animation hooks**, which acdream currently defers until
|
||||
the final root/part pose has been published. This includes presentation
|
||||
hooks such as particles and sounds, but also semantically relevant
|
||||
`CallPES`, default-script chaining, and translucency hooks.
|
||||
|
||||
That split is an acdream presentation boundary. It does not change retail's
|
||||
logical hook order.
|
||||
That split is an acdream presentation boundary. Authored FIFO order is
|
||||
preserved within acdream's deferred non-`AnimationDone` stream, but moving that
|
||||
stream past retail's manager tail changes manager-relative timing; that is the
|
||||
registered TS-50 divergence.
|
||||
|
||||
## Named-retail sources
|
||||
|
||||
- `CPhysicsObj::process_hooks` `0x00511550`
|
||||
- `CPhysics::UseTime` `0x00509950`
|
||||
- `CPhysicsObj::animate_static_object` `0x00513DF0`
|
||||
- `CPhysicsObj::UpdatePositionInternal` `0x00512C30`
|
||||
- `CPhysicsObj::UpdateObjectInternal` `0x005156B0`
|
||||
- `CPhysicsObj::update_object` `0x00515D10`
|
||||
|
|
@ -58,6 +63,17 @@ CPhysicsObj::update_object
|
|||
-> PositionManager::UseTime
|
||||
-> ParticleManager::UpdateParticles
|
||||
-> ScriptManager::UpdateScripts
|
||||
|
||||
CPhysics::UseTime
|
||||
-> ordinary object table in the order above
|
||||
-> static_animating_objects
|
||||
-> CPhysicsObj::animate_static_object
|
||||
-> CPartArray::Update
|
||||
-> UpdatePartsInternal
|
||||
-> UpdateChildrenInternal
|
||||
-> ScriptManager::UpdateScripts
|
||||
-> ParticleManager::UpdateParticles
|
||||
-> CPhysicsObj::process_hooks
|
||||
```
|
||||
|
||||
## Pseudocode
|
||||
|
|
@ -66,11 +82,16 @@ CPhysicsObj::update_object
|
|||
|
||||
```text
|
||||
if parent != null or cell == null or state contains Frozen:
|
||||
clear Active transient state
|
||||
return
|
||||
|
||||
refresh active/view-distance state
|
||||
elapsed = current_physics_time - last_update_time
|
||||
|
||||
if elapsed <= FrameEpsilon (0.000199999995 seconds):
|
||||
last_update_time = current_physics_time
|
||||
return
|
||||
|
||||
if elapsed > HugeQuantum:
|
||||
last_update_time = current_physics_time
|
||||
return
|
||||
|
|
@ -85,8 +106,19 @@ if elapsed > MinQuantum:
|
|||
UpdateObjectInternal(elapsed)
|
||||
```
|
||||
|
||||
The comparisons are strict (`>`), not `>=`. A sub-minimum remainder stays in
|
||||
the clock for a later call.
|
||||
The comparisons are strict (`>`), not `>=`. Elapsed time at or below the
|
||||
roughly 0.2-millisecond frame epsilon is explicitly rebased/discarded. Above
|
||||
that epsilon, a remainder at or below `MinQuantum` stays in the clock for a
|
||||
later call; `MaxQuantum` controls subdivision and `HugeQuantum` controls stale
|
||||
discard. These are separate gates.
|
||||
|
||||
`UpdateObjectInternal` then branches on the transient Active bit. An inactive
|
||||
object skips position integration, hook processing, Detection, Target,
|
||||
Movement, PartArray, and PositionManager, but still advances its
|
||||
ParticleManager and ScriptManager tails. A Hidden object that remains Active
|
||||
is different: it enters `UpdatePositionInternal`, suppresses only PartArray
|
||||
advance and physics integration, and retains PositionManager adjustment,
|
||||
hook processing, and the complete manager tail.
|
||||
|
||||
### `CPhysicsObj::UpdatePositionInternal(dt, candidate)`
|
||||
|
||||
|
|
@ -120,13 +152,25 @@ manager tail.
|
|||
### `CPhysicsObj::process_hooks`
|
||||
|
||||
```text
|
||||
execute each queued physics hook in FIFO order
|
||||
clear physics-hook queue
|
||||
for each physics hook present in the linked list walk:
|
||||
completed = hook.Execute(owner)
|
||||
if completed:
|
||||
unlink and destroy that hook
|
||||
else:
|
||||
retain it for a later object update
|
||||
|
||||
execute each queued animation hook in FIFO order
|
||||
clear animation-hook queue
|
||||
for i from zero while i < current animation-hook count:
|
||||
animation_hooks[i].Execute(owner)
|
||||
// the bound is live: a hook appended during execution can run in this drain
|
||||
shrink animation-hook storage and set the count to zero
|
||||
```
|
||||
|
||||
The physics-hook walk snapshots each current node's `next` pointer before
|
||||
execution. A completed hook is removed; an incomplete hook remains linked.
|
||||
The animation-hook loop deliberately rereads `m_num` after each callback, so
|
||||
reentrant append is consumed in the same drain. Neither collection is an
|
||||
unconditional snapshot-and-clear queue.
|
||||
|
||||
For an `AnimDoneHook`:
|
||||
|
||||
```text
|
||||
|
|
@ -175,8 +219,52 @@ if script_manager != null:
|
|||
```
|
||||
|
||||
The tail is reached for both visible and Hidden objects when the object quantum
|
||||
runs. A missing manager is simply skipped. No manager is reordered around
|
||||
another manager based on local/remote/player/NPC classification.
|
||||
runs and the object is Active. An inactive object skips this position/manager
|
||||
body but still reaches Particle then Script. A missing manager is simply
|
||||
skipped. No manager is reordered around another manager based on
|
||||
local/remote/player/NPC classification.
|
||||
|
||||
### `CPhysicsObj::animate_static_object`
|
||||
|
||||
```text
|
||||
if cell == null:
|
||||
return
|
||||
|
||||
elapsed = current_time - update_time
|
||||
if elapsed <= FrameEpsilon (0.000199999995 seconds):
|
||||
update_time = current_time
|
||||
return
|
||||
if elapsed > HugeQuantum:
|
||||
update_time = current_time
|
||||
return
|
||||
|
||||
if part_array != null:
|
||||
if state contains HasDefaultAnim:
|
||||
part_array.Update(elapsed)
|
||||
rotate root by the current angular vector
|
||||
UpdatePartsInternal()
|
||||
UpdateChildrenInternal()
|
||||
|
||||
if state contains HasDefaultScript and script_manager != null:
|
||||
script_manager.UpdateScripts()
|
||||
|
||||
if particle_manager != null:
|
||||
particle_manager.UpdateParticles()
|
||||
|
||||
process_hooks()
|
||||
|
||||
update_time = current_time
|
||||
```
|
||||
|
||||
The static workset has no ordinary-object `MinQuantum` remainder gate or
|
||||
`MaxQuantum` subdivision loop: it uses only the frame epsilon and
|
||||
`HugeQuantum`, then passes the complete admitted elapsed time to PartArray.
|
||||
It therefore has a distinct Script → Particle → hooks tail, not ordinary
|
||||
objects' Particle → Script tail. acdream currently shares one
|
||||
ParticleSystem and one PhysicsScriptRunner across every owner and advances
|
||||
them once after the complete ordinary/static worksets. That is the registered
|
||||
TS-51 adaptation and must not be silently reordered by an ownership-only
|
||||
extraction.
|
||||
|
||||
## acdream conformance boundary
|
||||
|
||||
|
|
@ -186,9 +274,11 @@ unsupported `DetectionManager` is represented by an absent callback rather
|
|||
than an invented behavior.
|
||||
|
||||
Animation-hook capture performs `AnimationDone` control effects immediately,
|
||||
at the `process_hooks` boundary. Pose-dependent routing remains deferred until
|
||||
the current frame's root/part poses are published. Deferred routing must never
|
||||
invoke `AnimationDone` a second time and must never own
|
||||
at the `process_hooks` boundary. Every other captured animation hook currently
|
||||
remains deferred until the final root/part poses are published, including
|
||||
particle/audio/light/translucency hooks, `CallPES`, and default-script
|
||||
chaining. Deferred routing must never invoke `AnimationDone` a second time and
|
||||
must never own
|
||||
`MotionTableManager.UseTime`; that belongs only to the PartArray manager-tail
|
||||
slot.
|
||||
|
||||
|
|
@ -199,17 +289,32 @@ slot.
|
|||
2. Target runs before Movement; Movement runs before PartArray; PartArray runs
|
||||
before PositionManager.
|
||||
3. A missing manager does not shift or duplicate another callback.
|
||||
4. Hidden objects skip sequence advance but retain the same manager tail.
|
||||
5. A visual hook sees a pose published after capture, while an `AnimDoneHook`
|
||||
changes motion state at capture time.
|
||||
6. Deferred visual-hook drain never completes an animation twice and never
|
||||
performs a zero-tick motion sweep.
|
||||
7. A sub-MinQuantum frame performs no object-tail call; accumulated time is
|
||||
consumed by a later quantum.
|
||||
4. Parented, cell-less, and Frozen objects return before clock admission.
|
||||
5. Inactive objects skip position/hooks/manager time but retain Particle then
|
||||
Script; Hidden + Active objects skip sequence/physics but retain adjustment,
|
||||
hooks, and the manager tail.
|
||||
6. A non-`AnimationDone` hook sees a pose published after capture, while an
|
||||
`AnimDoneHook` changes motion state at capture time.
|
||||
7. Deferred hook drain never completes an animation twice and never performs
|
||||
a zero-tick motion sweep; representative `CallPES`, default-script, and
|
||||
translucency hooks retain their current deferred path.
|
||||
8. An incomplete physics hook remains linked, and an animation hook appended
|
||||
by another animation hook executes in the same retail drain.
|
||||
9. Static objects retain their distinct Script → Particle → hooks order.
|
||||
10. Ordinary epsilon/Min/Max/Huge boundaries and static epsilon/Huge
|
||||
boundaries preserve their separate strict retail comparisons.
|
||||
|
||||
## Cross-reference notes
|
||||
|
||||
ACE's physics/motion ports remain interpretation aids for manager ownership
|
||||
and state naming; the retail binary above is authoritative for order. The
|
||||
existing `RemoteChaseEndToEndHarnessTests` hand-codes acdream's pre-R6 order
|
||||
and is therefore a migration target, not an oracle.
|
||||
ACE's `PhysicsObj.cs` corroborates ordinary Particle → Script and static
|
||||
Script → Particle → hooks order. It does **not** corroborate the retail clock
|
||||
or hook-lifetime gates: ACE uses its server `TickRate`/Min/Max policy and
|
||||
clears physics hooks unconditionally, unlike retail's epsilon/Huge behavior
|
||||
and completion-based hook retention. ACViewer exposes the same client-shaped
|
||||
methods and manager order but shares ancestry with ACE, so that agreement is
|
||||
corroboration rather than a second oracle. holtburger's native protocol crates
|
||||
have no client physics-loop equivalent (its vendored ACViewer copy is not
|
||||
independent). The retail binary above is authoritative for every boundary.
|
||||
The existing
|
||||
`RemoteChaseEndToEndHarnessTests` hand-codes acdream's pre-R6 order and is
|
||||
therefore a migration target, not an oracle.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
# Retail graceful character logout
|
||||
|
||||
## Oracle
|
||||
|
||||
- `Proto_UI::LogOffCharacter` `0x00546A20`
|
||||
- `CPlayerSystem::RequestLogOff` `0x00562DD0`
|
||||
- inbound login-message dispatch at `0x0055C963`
|
||||
- `CPlayerSystem::ExecuteLogOff` `0x0055D780`
|
||||
- `ClientNet::ExitWorldDisconnect` `0x00541E00`
|
||||
- `ClientNet::LogOffServer` `0x00543EF0`
|
||||
- `SharedNet::SendOptionalHeader` `0x00543160`
|
||||
- `CPlayerSystem::LogOffCharacter` `0x00563520`
|
||||
|
||||
Cross-checks:
|
||||
|
||||
- ACE `CharacterHandler.CharacterLogOff` begins asynchronous player removal.
|
||||
- ACE `Session.SendFinalLogOffMessages` sends the opcode-only `CharacterLogOff`
|
||||
confirmation only after the player no longer owns a landblock, then returns
|
||||
the session to `AuthConnected`.
|
||||
- Holtburger currently sends opcode-only and disconnects immediately. Its own
|
||||
comment records that AC normally waits for the server response. That shortcut
|
||||
is not the retail oracle and was the source of acdream's reconnect race.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
LogOffCharacter(force):
|
||||
save player module
|
||||
if force:
|
||||
ExecuteLogOff()
|
||||
return
|
||||
if no interaction is pending:
|
||||
RequestLogOff()
|
||||
else:
|
||||
print "Logging off..."
|
||||
remember that logout must begin after the interaction
|
||||
|
||||
RequestLogOff():
|
||||
print "Logging off..."
|
||||
send_to_logon([0xF653, active_player_id])
|
||||
logOffRequested = true
|
||||
logOffRequestTime = now + 3 seconds
|
||||
if player is PK:
|
||||
logOffRequestTime += 20 seconds
|
||||
|
||||
on inbound login message 0xF653:
|
||||
ExecuteLogOff()
|
||||
|
||||
ExecuteLogOff():
|
||||
clear player-login state
|
||||
for every live ReceiverData connection:
|
||||
send a cleartext, empty DISCONNECT optional header
|
||||
use sequence 0 plus that receiver's network id and iteration
|
||||
disconnect the world connection
|
||||
clear the active player id
|
||||
```
|
||||
|
||||
## acdream close-only adaptation
|
||||
|
||||
acdream does not yet return to character selection when its native window is
|
||||
closed. It retains retail's important ordering: send the eight-byte request,
|
||||
continue receiving until the server's opcode-only confirmation arrives, then
|
||||
send the transport `DISCONNECT` and release the socket. World callbacks are not
|
||||
dispatched while shutdown drains the raw receive queue because their App owners
|
||||
may already be tearing down. A bounded 35-second wait covers retail's additional
|
||||
20-second PK logout delay and is only a transport safety
|
||||
limit; the normal path advances on the authoritative confirmation, not elapsed
|
||||
time. The connected gate separately observes ACE accepting the transport header
|
||||
before opening a replacement process; ACE removes the authenticated session on
|
||||
its world-manager tick, after the UDP sender has already completed.
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
# Retail live-session lifecycle pseudocode
|
||||
|
||||
**Scope:** GameWindow Slice 3 character-list, character-entry, and transport-
|
||||
disconnect conformance corrections.
|
||||
|
||||
## Named-retail oracle
|
||||
|
||||
- `CharacterIdentity::UnPack @ 0x004FE880`
|
||||
- `CharacterSet::UnPack @ 0x004FE340`
|
||||
- `CharacterSet::GetGreyedOutFor @ 0x004FDFA0`
|
||||
- `gmCharacterManagementUI::EnterGame @ 0x004ED440`
|
||||
- `gmCharGenMainUI::Update @ 0x004E8460`
|
||||
- `CPlayerSystem::Handle_Login__CharacterSet @ 0x0055F6D0`
|
||||
- `Proto_UI::SendEnterWorldRequest @ 0x00546A00`
|
||||
- `CPlayerSystem::Handle_Character__EnterGame_ServerReady @ 0x0055E4F0`
|
||||
- `Proto_UI::SendEnterWorld @ 0x00546BC0`
|
||||
- `CPlayerSystem::LogOnCharacter @ 0x0055F890`
|
||||
- `CPlayerSystem::UseTime @ 0x00563110`
|
||||
- `Proto_UI::LogOffCharacter @ 0x00546A20`
|
||||
- `CPlayerSystem::RequestLogOff @ 0x00562DD0`
|
||||
- inbound F653 dispatch `@ 0x0055C963`
|
||||
- `CPlayerSystem::ExecuteLogOff @ 0x0055D780`
|
||||
- `ClientNet::ExitWorldDisconnect @ 0x00541E00`
|
||||
- `ClientNet::LogOffServer @ 0x00543EF0`
|
||||
- `SharedNet::SendOptionalHeader @ 0x00543160`
|
||||
|
||||
The verbatim layouts are `CharacterIdentity` and `CharacterSet` in
|
||||
`docs/research/named-retail/acclient.h` (types 3206 and 3209).
|
||||
|
||||
## CharacterSet wire decode
|
||||
|
||||
```text
|
||||
CharacterIdentity.UnPack(cursor):
|
||||
identity.guid = read_u32(cursor)
|
||||
identity.name = read_PString(cursor)
|
||||
identity.secondsGreyedOut = read_u32(cursor)
|
||||
align cursor to 4 bytes
|
||||
|
||||
CharacterSet.UnPack(cursor):
|
||||
result.status = read_u32(cursor)
|
||||
|
||||
activeCount = read_u32(cursor)
|
||||
result.active = empty
|
||||
repeat activeCount times:
|
||||
result.active.append(CharacterIdentity.UnPack(cursor))
|
||||
|
||||
deletedCount = read_u32(cursor)
|
||||
result.deleted = empty
|
||||
repeat deletedCount times:
|
||||
result.deleted.append(CharacterIdentity.UnPack(cursor))
|
||||
|
||||
result.numAllowedCharacters = read_u32(cursor)
|
||||
result.account = read_PString(cursor)
|
||||
result.useTurbineChat = read_u32(cursor)
|
||||
result.hasThroneOfDestiny = read_u32(cursor)
|
||||
```
|
||||
|
||||
The two values previously described as "leading/trailing padding" are the
|
||||
retail status and deleted-character count. ACE currently writes zero for both,
|
||||
which hid the schema error in acdream and in Holtburger's current reader.
|
||||
|
||||
Cross-checks:
|
||||
|
||||
- ACE `GameMessageCharacterList` writes the same active identity records,
|
||||
allowed-slot count, canonical `Session.Account`, and booleans, but currently
|
||||
emits status/deleted-count as zero and no deleted records.
|
||||
- Holtburger `CharacterListData` agrees on active records and the tail, but
|
||||
likewise labels both zero values as padding. The named retail `CharacterSet`
|
||||
layout and `UnPack` control flow win where the references differ.
|
||||
|
||||
## Retail selection and acdream unattended entry
|
||||
|
||||
```text
|
||||
gmCharacterManagementUI.EnterGame():
|
||||
playerSystem = GetPlayerSystem()
|
||||
if playerSystem exists
|
||||
and selectedGuid != 0
|
||||
and CharacterSet.GetGreyedOutFor(
|
||||
CharacterSet.GetSlot(selectedGuid)) <= 0:
|
||||
show entering-world dialog
|
||||
playerSystem.LogOnCharacter(selectedGuid)
|
||||
```
|
||||
|
||||
Retail ordinarily requires an explicit selected active character. The only
|
||||
automatic retail path found is `gmCharGenMainUI::Update`: after character
|
||||
creation it walks the active list, skips entries greyed for a positive number
|
||||
of seconds, matches the newly-created name case-insensitively, and logs that
|
||||
GUID on.
|
||||
|
||||
acdream does not yet have the retained character-management screen. Its
|
||||
unattended auto-entry is therefore an explicit adaptation with a narrow rule:
|
||||
|
||||
```text
|
||||
for each identity in active wire order:
|
||||
if identity.guid != 0 and identity.secondsGreyedOut == 0:
|
||||
select this active-list index
|
||||
stop
|
||||
if none matched:
|
||||
do not enter a character
|
||||
```
|
||||
|
||||
Deleted entries are never candidates. This preserves the established "first
|
||||
available character" behavior without treating a pending-deletion character
|
||||
as available.
|
||||
|
||||
## Canonical account used by EnterWorld
|
||||
|
||||
```text
|
||||
CPlayerSystem.LogOnCharacter(selectedGuid), when ready:
|
||||
selectedGuid = persistentData.selectedCharacter
|
||||
account = playerSystem.account // populated from CharacterSet.account
|
||||
Proto_UI.SendEnterWorld(account, selectedGuid)
|
||||
ClientNet.EnterWorld()
|
||||
```
|
||||
|
||||
The account string in the final F657 message comes from the server-returned
|
||||
`CharacterSet.account`, not from the spelling typed into the startup login
|
||||
form. acdream must therefore make `WorldSession.EnterWorld` consume
|
||||
`Characters.AccountName` internally.
|
||||
|
||||
## Logout and negotiated transport disconnect
|
||||
|
||||
```text
|
||||
ClientNet.LogOffServer():
|
||||
packet = make optional-header packet(flags = Disconnect)
|
||||
for each negotiated ReceiverData:
|
||||
SharedNet.SendOptionalHeader(packet, receiver.address, receiver)
|
||||
logOffSent = true
|
||||
logonReceiverId = 0
|
||||
ExitWorldDisconnect()
|
||||
```
|
||||
|
||||
The in-world character path remains:
|
||||
|
||||
```text
|
||||
send F653 + active character GUID
|
||||
wait for server's opcode-only F653
|
||||
send negotiated transport Disconnect
|
||||
release transport
|
||||
```
|
||||
|
||||
ACE's `GameMessageCharacterLogOff` confirms the server form is exactly four
|
||||
bytes. Its `NetworkSession` terminates a session when it receives a packet with
|
||||
the transport Disconnect flag.
|
||||
|
||||
The transport Disconnect is connection-scoped, not character-state-scoped.
|
||||
Once acdream has accepted ConnectRequest receiver id/iteration, disposal sends
|
||||
it whether the session is still selecting a character, entering the world, in
|
||||
world, or failed later. F653 remains restricted to an active in-world
|
||||
character. A pre-negotiation failure has no receiver identity and only closes
|
||||
the local socket.
|
||||
|
||||
## Deliberately unchanged registered gaps
|
||||
|
||||
- UN-6: the fixed 200 ms ConnectResponse delay remains registered.
|
||||
- TS-28: LoginComplete readiness remains a later protocol slice.
|
||||
- AD-44: closing exits rather than returning to a retained character screen.
|
||||
|
|
@ -181,7 +181,7 @@ is the opcode itself, followed immediately by the payload.
|
|||
| 0xF619 | PositionAndMovement | GM | S→C | unhandled | P4 | Ghost opcode — declared but never fired by ACE. |
|
||||
| 0xF625 | ObjDescEvent | GM | S→C | unhandled | P1 | `u32 guid, ObjectDescription` — full re-send of visual description (body parts, textures, palettes). Critical for seeing other players' gear changes. |
|
||||
| 0xF643 | CharacterCreateResponse / CharacterRestoreResponse | GM | S→C | unhandled | P0+ | `u32 responseCode` — login-phase, only relevant after char-create. Same opcode, two semantics (disambiguated by session state). |
|
||||
| 0xF653 | CharacterLogOff | GM | bi | partial | P0 | No payload. Client sends before Disconnect to release the character lock immediately. acdream sends it from `Dispose`. |
|
||||
| 0xF653 | CharacterLogOff | GM | bi | parsed | P0 | Client request is opcode + active character GUID; server confirmation is opcode-only. acdream waits for confirmation before transport Disconnect. |
|
||||
| 0xF655 | CharacterDelete | GM | bi | unhandled | P4 | `u32 slot` — char-select-screen deletion. |
|
||||
| 0xF656 | CharacterCreate | GM | C→S | unhandled | P4 | Full character-creation blob — heritage, gender, starting town, appearance. |
|
||||
| 0xF657 | CharacterEnterWorld | GM | C→S | done | P0 | `u32 characterGuid, string16L account`. Built by `Messages/CharacterEnterWorld.cs`. |
|
||||
|
|
@ -859,8 +859,9 @@ All the P3/P4 tails. Ship when we need them, not before.
|
|||
- **0x00A3/0x00A4 FellowshipQuit/Dismiss** — same pairing; C→S in
|
||||
GameAction, S→C in GameEvent.
|
||||
- **0xF653 CharacterLogOff** — bi-directional, same opcode, no
|
||||
envelope. Client sends to request logout; server sends the echo
|
||||
before Disconnect.
|
||||
envelope. Retail's client request carries the active character GUID;
|
||||
the server confirmation is opcode-only. The client waits for that
|
||||
confirmation before disconnecting the world transport.
|
||||
- **0x01A8 MagicRemoveSpell** — C→S is GameAction, S→C is GameEvent.
|
||||
- **0xF7DE TurbineChat** — bi-directional GameMessage. Blob layout
|
||||
includes a `ChatNetworkBlobType` discriminant (EVENT_BINARY=1,
|
||||
|
|
|
|||
65
docs/research/skill_formula_calculate_pseudocode.md
Normal file
65
docs/research/skill_formula_calculate_pseudocode.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Retail skill-formula calculation
|
||||
|
||||
## Oracle
|
||||
|
||||
- Named retail: `SkillFormula::Calculate @ 0x00591960` in
|
||||
`named-retail/acclient_2013_pseudo_c.txt`.
|
||||
- Retail layout: `SkillFormula` in `named-retail/acclient.h:40199` stores six
|
||||
unsigned 32-bit fields in wire order: `W`, `X`, `Y`, `Z`, `Attr1`, `Attr2`.
|
||||
|
||||
The signed-looking branches in the recovered x87 code are not signed formula
|
||||
semantics. They are the compiler's unsigned-32-bit-to-floating-point fix-up:
|
||||
when the high bit is set, it adds `2^32` after the initial signed conversion.
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
bool SkillFormula.Calculate(uint attribute1, uint attribute2, out uint result)
|
||||
{
|
||||
uint divisor = Z;
|
||||
if (divisor == 0)
|
||||
return false;
|
||||
|
||||
// Every operation on this line is uint32 and wraps modulo 2^32.
|
||||
uint numerator = X * attribute1 + Y * attribute2 + W;
|
||||
|
||||
// Formula values are non-negative. Retail rounds to nearest integer,
|
||||
// with exact halves rounding upward.
|
||||
result = uint(floor(double(numerator) / double(divisor) + 0.5));
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
Consequences pinned by the port:
|
||||
|
||||
- `Z == 0` is the only failure gate. `X == 0` remains a valid formula.
|
||||
- `W` participates in the numerator before division; it is not added after
|
||||
division.
|
||||
- multiplication and addition wrap as unsigned 32-bit arithmetic before the
|
||||
floating-point division.
|
||||
- the division rounds half-up; it does not truncate.
|
||||
|
||||
## Reference cross-check
|
||||
|
||||
ACE's [`SkillFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.DatLoader/Entity/SkillFormula.cs)
|
||||
confirms the unsigned DAT order `W, X, Y, Z, Attr1, Attr2`. ACE's
|
||||
[`AttributeFormula.GetFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Entity/AttributeFormula.cs)
|
||||
is only an interpretation aid: it rejects `X == 0`, assumes unit multipliers,
|
||||
omits `W`, and rounds a summed-attribute quotient. That is sufficient for
|
||||
ordinary stock ACE data but is not the general retail algorithm.
|
||||
|
||||
Chorizite DatReaderWriter's generated
|
||||
[`SkillFormula`](https://github.com/Chorizite/DatReaderWriter/blob/master/DatReaderWriter/Generated/Types/SkillFormula.generated.cs)
|
||||
independently confirms the same six-field order and maps its public names to
|
||||
`W`, `X`, `Y`, and `Z`. It exposes the four arithmetic words as signed `int`,
|
||||
so the retail port must reinterpret their bits as `uint`. Its summary places
|
||||
the additive value after division; the named retail function proves that the
|
||||
actual client adds `W` to the numerator instead.
|
||||
|
||||
## Port decision
|
||||
|
||||
`RetailSkillFormula` is the sole App-layer calculation seam. It reinterprets
|
||||
the DAT words as unsigned, performs the exact unchecked 32-bit numerator, and
|
||||
uses the retail half-up division. `LiveSkillCreditResolver` performs only the
|
||||
skill-table lookup and treats a missing attribute as zero, matching the prior
|
||||
live-property projection behavior.
|
||||
|
|
@ -1,5 +1,41 @@
|
|||
# Animation Runtime Crib
|
||||
|
||||
## Current live presentation ownership (2026-07-21)
|
||||
|
||||
`LiveEntityAnimationScheduler` owns ordinary-object time, PartArray advance,
|
||||
root motion, and the retail manager tail. `RetailStaticAnimatingObjectScheduler`
|
||||
owns the separate retail static-object workset. `LiveEntityAnimationPresenter`
|
||||
is the sole consumer that composes final drawable part transforms, publishes
|
||||
rigid part/effect poses, and binds `MotionDone`. `GameWindow` wires these owners
|
||||
but no longer contains the presentation body.
|
||||
|
||||
The frame handoff is identity- and lifetime-bound. A schedule carries the exact
|
||||
`LiveEntityRecord`, `WorldEntity`, animation state, sequencer, object-clock
|
||||
epoch, projection mutation version, and presentation revision. Both ordinary
|
||||
and static schedulers copy the sequencer's borrowed PartArray view into their
|
||||
own buffers before invoking callbacks. Presentation is non-reentrant because
|
||||
it consumes legacy elapsed time and static prepared frames exactly once.
|
||||
|
||||
Appearance rebinding increments `PresentationRevision`. That rejects a pose
|
||||
prepared against the old part template without replacing the live animation
|
||||
component. For a static owner, rejecting only that stale visual pose must retain
|
||||
the same sequencer's pending `process_hooks` tail so `MotionDone` is not lost;
|
||||
owner, sequencer, residency, projection, or clock mismatches still invalidate
|
||||
the entire handoff.
|
||||
|
||||
Retail `CPartArray::UpdateParts` updates only the authored AnimFrame prefix.
|
||||
When a frame contains fewer parts than the Setup, trailing parts keep their
|
||||
previous pose (or their rest pose on first presentation) in both channels:
|
||||
|
||||
- visual: `DefaultScale * rotation * translation(origin) * ObjScale`;
|
||||
- rigid/effect: `rotation * translation(origin * ObjScale)`.
|
||||
|
||||
Canonical per-frame order remains scheduler tick, static tick, live presenter,
|
||||
equipped-child update, static `process_hooks`, deferred hook drain, attached
|
||||
effects/lights, particles, and draw. The end-to-end order assertion belongs to
|
||||
Slice 6's extracted update orchestrator; do not reintroduce local ordering into
|
||||
the presenter.
|
||||
|
||||
## Live entity lifecycle invariant
|
||||
|
||||
`CreateObject` creates a live entity and a real `DeleteObject` destroys it.
|
||||
|
|
|
|||
213
memory/project_gamewindow_decomposition.md
Normal file
213
memory/project_gamewindow_decomposition.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# GameWindow decomposition — current truth
|
||||
|
||||
## Current state
|
||||
|
||||
The behavior-preserving App ownership campaign is complete through Slice 7 and
|
||||
Slice 8 checkpoints A–H. `GameWindow.cs` moved from the 2026-07-21 baseline of
|
||||
15,723 lines / 278 fields / 205 methods to 3,689 lines / 162 fields / 37
|
||||
methods: 12,034 lines (76.5%) were removed without changing accepted gameplay
|
||||
or rendering behavior.
|
||||
|
||||
| Slice | Ownership moved out | Closeout size |
|
||||
|---|---|---:|
|
||||
| 1 | world selection and interaction intent | 14,912 / 278 / 191 |
|
||||
| 2 | live animation presentation | 14,546 / 277 / 190 |
|
||||
| 3 | complete live-session lifecycle and routing | 14,310 / 274 / 190 |
|
||||
| 4 | live-entity hydration, teardown, and network presentation | 10,301 / 267 / 163 |
|
||||
| 5 | landblock build, publication, retirement, and shared-origin lifetime | 8,811 / 247 / 153 |
|
||||
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
|
||||
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
|
||||
| 8 A–E | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 |
|
||||
| 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 |
|
||||
| 8 G | pre-window/runtime settings ownership, one startup snapshot, inert late target binding | 3,663 / 162 / 37 |
|
||||
| 8 H | render/UI/frame/portal resource roots plus checked retryable GL leaves | 3,689 / 162 / 37 |
|
||||
|
||||
Slice 8 is an ordered checkpoint campaign rather than another feature-body
|
||||
move. A froze/pruned the shell boundary; B gave all nine native window callback
|
||||
edges one reversible owner plus host quiescence; C added `LiveSessionHost` for
|
||||
reset/selection/entry/route composition while keeping `LiveSessionController`
|
||||
the sole session owner. C also replaced the embedded ACE shortcut with the
|
||||
named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route
|
||||
construction and individual detach edges are retained and retried; reset and a
|
||||
new generation cannot pass an incompletely detached route. D added
|
||||
`WorldEnvironmentController` as the one-shot owner of WorldTime, loaded sky,
|
||||
day-group selection, Weather, server synchronization, AdminEnvirons, and debug
|
||||
cycles. `GameWindow` retains only ABI-compatible readonly aliases and typed
|
||||
composition edges. The named-oracle correction is
|
||||
`SkyDesc::CalcPresentDayGroup @ 0x00500E10`; the carried AdminEnvirons audio and
|
||||
full fog/ambient/radar gaps are TS-54/TS-55.
|
||||
|
||||
E adds `CameraPointerInputController`, `FramebufferResizeController`, terminal
|
||||
publish-before-attach Silk/dispatcher/retained input owners, and a quiescent
|
||||
non-owning input-context view for optional devtools. Mouse-only hosts still
|
||||
receive raw camera/cursor behavior. Logical callback deactivation runs before
|
||||
the graceful session close; physical removal and the local mouse-look release
|
||||
run only after session retirement. This prevents both copied callback reentry
|
||||
and a failed event accessor from blocking F653/transport teardown. The carried
|
||||
non-retail post-filter camera scalars are explicitly registered as TS-56.
|
||||
|
||||
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.
|
||||
|
||||
G adds `RuntimeSettingsController` as the sole concrete settings-store,
|
||||
current-state, startup-snapshot, resolved-quality, active-toon, and optional
|
||||
Settings-draft owner. It is constructed before `Window.Create`; startup applies
|
||||
monitor pacing, window state, saved FOV, and audio in order once, with
|
||||
substep-granular retry after failure. Complete runtime targets bind only after
|
||||
UI/terrain/dispatcher/render-range/streaming exist and binding performs no
|
||||
replay. The Settings save order and five-step quality application order remain
|
||||
frozen. Combat mutations preserve unrelated unsaved Gameplay draft fields, and
|
||||
successful external gameplay commands advance only their fields in the
|
||||
SettingsVM persisted baseline so Cancel cannot resurrect stale command state.
|
||||
UI-lock target, persistence, and baseline publication share an explicit
|
||||
convergence flag so target/save failures remain retryable for the same value.
|
||||
Saved FOV now applies without devtools, fixing the former accidental
|
||||
SettingsVM gate. AP-121 records the intentional modern persistence added to
|
||||
the named-retail `/framerate` live toggle/notice.
|
||||
|
||||
H adds explicit ownership for the terrain atlas, dedicated sky shader,
|
||||
retained Host/runtime chain, atomic update/render root pair, and portal tunnel
|
||||
fallback/preparation/transfer. Every GL construction or mutation prefix added
|
||||
to the checkpoint advances CPU ownership only after an always-on checked
|
||||
driver commit. Failed shader/program/texture/buffer/VAO/text construction
|
||||
retains exact names through the construction ledger; bindless residency and
|
||||
the prior texture binding retain retry obligations across transient failure.
|
||||
UI input cutoff and resource slots reject reentrant ownership mutation, and
|
||||
portal preparation retries the same published fallback instead of replaying
|
||||
the factory. This is a lifetime-only cutover; no AC algorithm or accepted
|
||||
presentation order changed.
|
||||
|
||||
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
|
||||
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
|
||||
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
|
||||
`6d6e5b5f`, `85239fb3`, `28e1cf80`, and production cutover `9d7df1bf`.
|
||||
The detailed ledgers live under `docs/plans/2026-07-2*-gamewindow-slice-*.md` and
|
||||
`docs/architecture/code-structure.md`.
|
||||
|
||||
## Frozen update graph
|
||||
|
||||
`GameWindow.OnUpdate` starts the profiler scope and calls one
|
||||
`UpdateFrameOrchestrator.Tick`. The accepted production graph is:
|
||||
|
||||
1. retry retained live-entity teardown;
|
||||
2. normalize/publish the update and PhysicsScript clocks;
|
||||
3. converge streaming origin, residence, readiness, and rescued projections;
|
||||
4. sample semantic input, raw mouse, and combat intent;
|
||||
5. advance live objects and deferred presentation hooks;
|
||||
6. drain inbound network traffic inside one world mutation batch;
|
||||
7. run the local post-network position tail;
|
||||
8. reconcile root/child/emitter/light presentation without advancing time;
|
||||
9. expire liveness;
|
||||
10. advance local teleport/reveal/tunnel presentation;
|
||||
11. evaluate one-shot player-mode entry;
|
||||
12. publish fly/chase/player camera presentation.
|
||||
|
||||
`LiveEntityRuntime` remains the canonical identity/incarnation owner.
|
||||
`GpuWorldState` remains the spatial bucket owner. `LiveSessionController` owns
|
||||
the session generation. Extracted phase owners query these typed runtimes and
|
||||
must not create replacement dictionaries, service locators, or callback
|
||||
facades into substantial `GameWindow` methods.
|
||||
|
||||
## Frozen render graph
|
||||
|
||||
`GameWindow.OnRender` creates one value-only `RenderFrameInput` and calls
|
||||
`RenderFrameOrchestrator.Render`. The accepted healthy order is:
|
||||
|
||||
1. begin the GPU-flight transaction;
|
||||
2. begin/reset frame resources, clear, and publish live resources;
|
||||
3. begin the optional ImGui frame;
|
||||
4. advance weather display preparation;
|
||||
5. draw the complete world/PView/shared-alpha/particle/debug scene;
|
||||
6. draw the portal/private viewport using the pre-private foundation snapshot;
|
||||
7. refresh/draw paperdoll, retained gameplay UI, then submit ImGui;
|
||||
8. capture a requested screenshot;
|
||||
9. publish title/resource/frame diagnostics;
|
||||
10. close the GPU-flight transaction.
|
||||
|
||||
Recovery is one reverse-lifetime transaction. An active ImGui frame closes
|
||||
through the owning Silk controller, and text rendering restores exact borrowed
|
||||
GL state on success or failure. The orchestrator and its immediate phase owners
|
||||
retain no direct `GameWindow` or anonymous callback bag. Recursive reachability
|
||||
through canonical UI/input owners is not frame ownership; the known paths and
|
||||
review rule live in `docs/architecture/code-structure.md`.
|
||||
|
||||
## Accepted verification
|
||||
|
||||
- Slice 8 Checkpoint H: 61 focused ownership/lifetime tests pass; App Release
|
||||
passes 3,236 tests / 3 intentional skips. Four corrected-diff cycles closed
|
||||
every architecture, retail, and adversarial finding; all three final reviews
|
||||
are clean. The production App build has zero warnings/errors. The complete
|
||||
Release suite passes 7,606 tests / 5 intentional skips; the 17 existing test-
|
||||
project warnings remain tracked by #228.
|
||||
- Slice 8 Checkpoint G Release suite: 7,553 passed / 5 fixture or environment
|
||||
skips; App 3,183/3 skips, UI Abstractions 534/0, 35 focused App
|
||||
settings/boundary tests, and all 43 `SettingsVMTests` pass. The production App
|
||||
build has zero warnings/errors; the complete solution retains the 17 existing
|
||||
test-project warnings tracked by #228. All three independent corrected-diff
|
||||
reviews are clean.
|
||||
- Slice 8 Checkpoint F Release suite: 7,524 passed / 5 fixture or environment
|
||||
skips; App 3,155/3 skips, 54 focused App ownership tests and 20 Core combat
|
||||
conformance tests pass, and all three independent corrected-diff reviews are
|
||||
clean.
|
||||
- Slice 8 Checkpoint E Release suite: 7,477 passed / 5 fixture or environment
|
||||
skips; App 3,108/3 skips, UI Abstractions 533/0, and all three independent
|
||||
corrected-diff reviews are clean.
|
||||
- Slice 8 Checkpoint D Release suite: 7,419 passed / 5 fixture or environment
|
||||
skips; App 3,059/3 skips. Focused environment/boundary tests and all three
|
||||
independent corrected-diff reviews are clean.
|
||||
- Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment
|
||||
skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected-
|
||||
diff reviews are clean.
|
||||
- Release suite: 7,341 passed / 5 fixture or environment skips.
|
||||
- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh
|
||||
process uncapped reconnect passed; only 25 expected world-edge empty
|
||||
landblocks.
|
||||
- The six lifecycle PNGs preserve Slice 6 world geometry, depth, UI/paperdoll,
|
||||
private viewport, and presentation. Differences are live weather/particles,
|
||||
authoritative vitals, and small scripted camera settling.
|
||||
- Synchronized nine-stop resource soak: 395.2 seconds, nine materializations,
|
||||
all movement/jump/combat exercises, and graceful exit passed; update p95 was
|
||||
at or below 0.8 ms. Caul return → plateau working/private delta was
|
||||
+85.7/+59.3 MiB, inside the unchanged gate.
|
||||
- Two immediately preceding identical-binary runs exceeded the coarse process-
|
||||
residency threshold while deterministic ownership/lifecycle/timing checks
|
||||
passed. #232 adds canonical resource-owner snapshots; do not solve this by
|
||||
loosening the process-memory threshold or treating residency alone as an
|
||||
identified owner leak.
|
||||
- The 18–33 missing VFX table/emitter diagnostics are known DAT-driven records,
|
||||
not a Slice 7 regression.
|
||||
- TS-50, TS-51, and TS-53 remain registered; Slice 7 introduced no new retail
|
||||
divergence.
|
||||
|
||||
## Next work
|
||||
|
||||
Slice 8 checkpoints I–L group the remaining ordered startup composition,
|
||||
shutdown wiring, canonical soak snapshots, and closeout and reduce Silk callbacks to
|
||||
narrow calls into input, update, render, resize, focus, and shutdown owners.
|
||||
It removes leftover feature state only where Slices 1–7 already established a
|
||||
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
|
||||
The reviewed A–L execution ledger is
|
||||
`docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md`.
|
||||
|
||||
## Do not retry
|
||||
|
||||
- Do not reintroduce delegate facades into `GameWindow`; an ordering wrapper is
|
||||
not an extraction until the state and behavior body move.
|
||||
- Do not reorder the frozen update or render graphs during structural work.
|
||||
TS-53 records the known retail host-order difference for a separate evidence-
|
||||
backed port.
|
||||
- Do not add a second GUID, session, spatial, or resource owner.
|
||||
- Do not fold full `GameEntity` aggregation into this campaign; evaluate it
|
||||
after Slice 8.
|
||||
- Do not loosen the soak process-memory threshold to hide #232. Add canonical
|
||||
owner snapshots and preserve process residency as a secondary guard.
|
||||
- Small value/policy callbacks and diagnostics are acceptable only when they do
|
||||
not call a substantial window body or hide mutable ownership.
|
||||
|
|
@ -213,3 +213,36 @@ plus optional stack size, palette, and shade. Healing kit WCIDs are 628 crude,
|
|||
629 plain, 630 good, 631 excellent, and 632 peerless. The `+Acdream` developer
|
||||
character can therefore create the best kit with either `/ci 632` or `@ci 632`;
|
||||
both forms must produce canonical `@ci 632` Talk text.
|
||||
|
||||
## 2026-07-21 selection extraction and inventory transaction ownership
|
||||
|
||||
`WorldSelectionQuery` is the read-only App boundary over canonical live state,
|
||||
the completed render-selection frame, object metadata, camera/cursor inputs,
|
||||
DAT selection bounds, hostile lookup, and Use/PickUp range classification. It
|
||||
never mutates `SelectionState`, starts movement, or sends packets.
|
||||
|
||||
`SelectionInteractionController` is the corresponding intent owner. It owns
|
||||
world-click routing, closest/previous selection, the outbound frame-boundary
|
||||
queue, Use/PickUp transport, and MoveTo deferral. Queued work captures both the
|
||||
live local entity ID and exact `ClientObject` reference where available;
|
||||
deferred movement uses cancel-before-arm ordering and a queue epoch prevents
|
||||
pre-reset work from crossing session lifetime.
|
||||
|
||||
`ItemInteractionController` owns the one global inventory transaction, shaped
|
||||
after retail `ACCWeenieObject::prevRequest`. Inventory, external-container,
|
||||
toolbar, paperdoll, give, merge, split, drop, and deferred keyboard pickup all
|
||||
acquire the same owner. A destination waiting projection carries an exact token
|
||||
and item identity. Keyboard pickup may remain reserved-but-undispatched while
|
||||
MoveTo runs, then promotes that exact token only when the wire request is sent.
|
||||
|
||||
`ClientObjectMoveOrigin` distinguishes immediate optimistic repaint, server
|
||||
rollback repaint, and authoritative response. Only authoritative move/failure/
|
||||
remove/stack responses complete the transaction. Completion clears the global
|
||||
owner and local waiting projection atomically, publishes the old token-specific
|
||||
projection resolution, then advertises readiness. This order is essential:
|
||||
reentrant observers may start the next request without the old response tail
|
||||
rejecting, hiding, or consuming it. Do not regress to GUID-only cleanup or let
|
||||
ordinary `ObjectUpdated` events complete inventory work.
|
||||
|
||||
`GameWindow` now constructs and forwards narrow lifecycle edges to these
|
||||
owners; no selection algorithm or interaction request body belongs there.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input;
|
|||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ICombatAttackOperations
|
||||
{
|
||||
bool CanStartAttack();
|
||||
void PrepareAttackRequest();
|
||||
bool SendAttack(AttackHeight height, float power);
|
||||
void SendCancelAttack();
|
||||
bool IsDualWield { get; }
|
||||
bool PlayerReadyForAttack { get; }
|
||||
bool AutoRepeatAttack { get; }
|
||||
}
|
||||
|
||||
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
|
||||
{
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
|
||||
public DelegateCombatAttackOperations(
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? prepareAttackRequest,
|
||||
Action? sendCancelAttack,
|
||||
Func<bool>? isDualWield,
|
||||
Func<bool>? playerReadyForAttack,
|
||||
Func<bool>? autoRepeatAttack)
|
||||
{
|
||||
_canStartAttack = canStartAttack
|
||||
?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
||||
}
|
||||
|
||||
public bool CanStartAttack() => _canStartAttack();
|
||||
public void PrepareAttackRequest() => _prepareAttackRequest();
|
||||
public bool SendAttack(AttackHeight height, float power) =>
|
||||
_sendAttack(height, power);
|
||||
public void SendCancelAttack() => _sendCancelAttack();
|
||||
public bool IsDualWield => _isDualWield();
|
||||
public bool PlayerReadyForAttack => _playerReadyForAttack();
|
||||
public bool AutoRepeatAttack => _autoRepeatAttack();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns retail's basic-combat attack request and power-bar state machine.
|
||||
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
|
||||
|
|
@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
public const float DesiredPowerStep = 1f / 6f;
|
||||
|
||||
private readonly CombatState _combat;
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Action _prepareAttackRequest;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
private readonly ICombatAttackOperations _operations;
|
||||
private readonly Func<double> _now;
|
||||
|
||||
private bool _buildInProgress;
|
||||
|
|
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
|
|||
Func<bool>? playerReadyForAttack = null,
|
||||
Func<bool>? autoRepeatAttack = null,
|
||||
Func<double>? now = null)
|
||||
: this(
|
||||
combat,
|
||||
new DelegateCombatAttackOperations(
|
||||
canStartAttack,
|
||||
sendAttack,
|
||||
prepareAttackRequest,
|
||||
sendCancelAttack,
|
||||
isDualWield,
|
||||
playerReadyForAttack,
|
||||
autoRepeatAttack),
|
||||
now)
|
||||
{
|
||||
}
|
||||
|
||||
internal CombatAttackController(
|
||||
CombatState combat,
|
||||
ICombatAttackOperations operations,
|
||||
Func<double>? now = null)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
|
||||
|
||||
_combat.CombatModeChanged += OnCombatModeChanged;
|
||||
|
|
@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
&& !_repeatAttacking)
|
||||
return;
|
||||
|
||||
_sendCancelAttack();
|
||||
_operations.SendCancelAttack();
|
||||
_repeatAttacking = false;
|
||||
|
||||
if (_buildInProgress)
|
||||
|
|
@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
if (!_buildInProgress)
|
||||
return;
|
||||
|
||||
if (!_playerReadyForAttack())
|
||||
if (!_operations.PlayerReadyForAttack)
|
||||
{
|
||||
if (_attackRequestInProgress)
|
||||
{
|
||||
|
|
@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
private void StartAttackRequest()
|
||||
{
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
||||
|| !_canStartAttack())
|
||||
|| !_operations.CanStartAttack())
|
||||
return;
|
||||
|
||||
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
|
||||
|
|
@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
// player movement object and must run before any later attack send.
|
||||
_attackRequestInProgress = true;
|
||||
_requestedAttackPower = 1f;
|
||||
_prepareAttackRequest();
|
||||
_operations.PrepareAttackRequest();
|
||||
_currentBuildIsAutomatic = false;
|
||||
AttemptStartBuildingAttack();
|
||||
}
|
||||
|
|
@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
if (_buildInProgress
|
||||
|| _attackServerResponsePending
|
||||
|| !_playerReadyForAttack())
|
||||
|| !_operations.PlayerReadyForAttack)
|
||||
return;
|
||||
StartPowerBarBuild();
|
||||
}
|
||||
|
|
@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
{
|
||||
if (!_buildInProgress)
|
||||
return 0f;
|
||||
double duration = _isDualWield()
|
||||
double duration = _operations.IsDualWield
|
||||
? DualWieldPowerUpSeconds
|
||||
: AttackPowerUpSeconds;
|
||||
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
|
||||
|
|
@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable
|
|||
private void ExecuteAttack(AttackHeight height, bool setServerPending)
|
||||
{
|
||||
StopBuild();
|
||||
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||
if (!_operations.SendAttack(
|
||||
height,
|
||||
Math.Clamp(_requestedAttackPower, 0f, 1f)))
|
||||
{
|
||||
ResetPowerBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoRepeatAttack())
|
||||
if (_operations.AutoRepeatAttack)
|
||||
_repeatAttacking = true;
|
||||
_attackServerResponsePending = setServerPending;
|
||||
}
|
||||
|
|
@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
_repeatAttacking = false;
|
||||
|
||||
if (!_attackRequestInProgress
|
||||
&& _autoRepeatAttack()
|
||||
&& _operations.AutoRepeatAttack
|
||||
&& _repeatAttacking)
|
||||
{
|
||||
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
|
||||
|
|
@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
ExecuteAttack(RequestedHeight, setServerPending: false);
|
||||
}
|
||||
|
||||
if (!_autoRepeatAttack() || !_repeatAttacking)
|
||||
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
|
||||
{
|
||||
_repeatAttacking = false;
|
||||
ResetPowerBar();
|
||||
|
|
@ -403,6 +461,19 @@ public sealed class CombatAttackController : IDisposable
|
|||
ResetPowerBar();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore the process-lived attack controller to retail's new-session
|
||||
/// defaults without sending a cancel packet through the displaced session.
|
||||
/// Mirrors <c>ClientCombatSystem::Begin @ 0x0056A460</c>.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
Reset();
|
||||
RequestedHeight = AttackHeight.Medium;
|
||||
DesiredPower = InitialDesiredPower;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
|
|
|||
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
116
src/AcDream.App/Combat/CombatAttackTargetSource.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
/// <summary>
|
||||
/// Focused automatic-attack target owner. It shares canonical selection and
|
||||
/// live-entity state without retaining the broader interaction controller.
|
||||
/// </summary>
|
||||
internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
|
||||
{
|
||||
private readonly SelectionState _selection;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly ILocalPlayerIdentitySource _player;
|
||||
|
||||
public CombatAttackTargetSource(
|
||||
SelectionState selection,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ClientObjectTable objects,
|
||||
ILocalPlayerIdentitySource player)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_liveEntities = liveEntities
|
||||
?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
}
|
||||
|
||||
public uint? SelectedObjectId => _selection.SelectedObjectId;
|
||||
|
||||
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected
|
||||
&& IsHostileMonster(selected))
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
|
||||
if (!autoTarget)
|
||||
return null;
|
||||
|
||||
(uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster();
|
||||
if (closest is not { } best)
|
||||
{
|
||||
_selection.Clear(SelectionChangeSource.Keyboard);
|
||||
return null;
|
||||
}
|
||||
|
||||
_selection.Select(best.Guid, SelectionChangeSource.Keyboard);
|
||||
string? name = _objects.Get(best.Guid)?.Name;
|
||||
string label = string.IsNullOrWhiteSpace(name)
|
||||
? $"0x{best.Guid:X8}"
|
||||
: name;
|
||||
Console.WriteLine(
|
||||
$"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}");
|
||||
return best.Guid;
|
||||
}
|
||||
|
||||
private (uint Guid, float DistanceSquared)? FindClosestHostileMonster()
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(
|
||||
_player.ServerGuid,
|
||||
out WorldEntity playerEntity))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
(uint Guid, float DistanceSquared)? best = null;
|
||||
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
|
||||
{
|
||||
if (!IsHostileMonster(guid))
|
||||
continue;
|
||||
|
||||
float distanceSquared = Vector3.DistanceSquared(
|
||||
entity.Position,
|
||||
playerEntity.Position);
|
||||
if (best is null || distanceSquared < best.Value.DistanceSquared)
|
||||
best = (guid, distanceSquared);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private bool IsHostileMonster(uint serverGuid)
|
||||
{
|
||||
uint playerGuid = _player.ServerGuid;
|
||||
if (serverGuid == playerGuid
|
||||
|| !_liveEntities.TryGetInteractionEligibleRecord(
|
||||
serverGuid,
|
||||
out LiveEntityRecord record)
|
||||
|| record.WorldEntity is not { } entity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation)
|
||||
&& animation.CurrentMotion == MotionCommand.Dead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ClientObject? candidate = _objects.Get(serverGuid);
|
||||
return candidate is not null
|
||||
&& (candidate.Type & ItemType.Creature) != 0
|
||||
&& CombatTargetPolicy.IsHostileMonster(
|
||||
playerGuid,
|
||||
_objects.Get(playerGuid),
|
||||
candidate);
|
||||
}
|
||||
}
|
||||
53
src/AcDream.App/Combat/CombatCameraTargetSource.cs
Normal file
53
src/AcDream.App/Combat/CombatCameraTargetSource.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ICombatCameraTargetSource
|
||||
{
|
||||
Vector3? GetTrackedTargetPoint();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves retail's combat-camera target from canonical combat, selection,
|
||||
/// gameplay-option, and world-selection owners.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ClientCombatSystem::UpdateTargetTracking @ 0x0056A950</c> enables
|
||||
/// <c>CameraSet::TrackTarget</c> only for melee/missile combat with the option
|
||||
/// enabled and a valid attack target. TrackTarget applies target-local offset
|
||||
/// (0, 0, 0.5).
|
||||
/// </remarks>
|
||||
internal sealed class CombatCameraTargetSource : ICombatCameraTargetSource
|
||||
{
|
||||
private readonly ICombatGameplaySettingsSource _settings;
|
||||
private readonly CombatState _combat;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly IWorldSelectionQuery _world;
|
||||
|
||||
public CombatCameraTargetSource(
|
||||
ICombatGameplaySettingsSource settings,
|
||||
CombatState combat,
|
||||
SelectionState selection,
|
||||
IWorldSelectionQuery world)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
}
|
||||
|
||||
public Vector3? GetTrackedTargetPoint()
|
||||
{
|
||||
if (!_settings.ViewCombatTarget
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|
||||
|| _selection.SelectedObjectId is not uint selected)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _world.GetCombatCameraTargetPoint(selected);
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,7 @@ public sealed class CombatTargetController : IDisposable
|
|||
private void OnSelectionChanged(SelectionTransition transition)
|
||||
{
|
||||
if (transition.SelectedObjectId is not null
|
||||
|| transition.Reason == SelectionChangeReason.SessionReset
|
||||
|| !_autoTarget()
|
||||
|| !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
return;
|
||||
|
|
|
|||
182
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
182
src/AcDream.App/Combat/LiveCombatAttackOperations.cs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Combat;
|
||||
|
||||
internal interface ICombatAttackTargetSource
|
||||
{
|
||||
uint? SelectedObjectId { get; }
|
||||
uint? GetSelectedOrClosestCombatTarget(bool autoTarget);
|
||||
}
|
||||
|
||||
internal interface ICombatGameplaySettingsSource
|
||||
{
|
||||
bool AutoTarget { get; }
|
||||
bool AutoRepeatAttack { get; }
|
||||
bool ViewCombatTarget { get; }
|
||||
}
|
||||
|
||||
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
|
||||
{
|
||||
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
|
||||
public bool AutoTarget => Value.AutoTarget;
|
||||
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
|
||||
public bool ViewCombatTarget => Value.ViewCombatTarget;
|
||||
}
|
||||
|
||||
internal interface ICombatFeedbackSink
|
||||
{
|
||||
void Show(string message);
|
||||
}
|
||||
|
||||
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
|
||||
{
|
||||
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
|
||||
public void Show(string message) => ViewModel?.AddToast(message);
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
|
||||
{
|
||||
private ICombatAttackOperations? _owner;
|
||||
|
||||
public void Bind(ICombatAttackOperations owner)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(owner);
|
||||
if (_owner is not null && !ReferenceEquals(_owner, owner))
|
||||
throw new InvalidOperationException(
|
||||
"Combat attack operations are already bound.");
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
|
||||
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
|
||||
public bool SendAttack(AttackHeight height, float power) =>
|
||||
_owner?.SendAttack(height, power) == true;
|
||||
public void SendCancelAttack() => _owner?.SendCancelAttack();
|
||||
public bool IsDualWield => _owner?.IsDualWield == true;
|
||||
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
|
||||
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production combat request dependencies. The attack state machine retains
|
||||
/// this typed owner rather than callbacks into the application window.
|
||||
/// </summary>
|
||||
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
|
||||
{
|
||||
private readonly CombatState _combat;
|
||||
private readonly ICombatAttackTargetSource _targets;
|
||||
private readonly ICombatGameplaySettingsSource _settings;
|
||||
private readonly ILocalPlayerControllerSource _player;
|
||||
private readonly LocalPlayerOutboundController _outbound;
|
||||
private readonly ILiveInWorldSource _inWorld;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
private readonly ICombatFeedbackSink _feedback;
|
||||
|
||||
public LiveCombatAttackOperations(
|
||||
CombatState combat,
|
||||
ICombatAttackTargetSource targets,
|
||||
ICombatGameplaySettingsSource settings,
|
||||
ILocalPlayerControllerSource player,
|
||||
LocalPlayerOutboundController outbound,
|
||||
ILiveInWorldSource inWorld,
|
||||
ILiveWorldSessionSource session,
|
||||
ICombatFeedbackSink feedback)
|
||||
{
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
_inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
|
||||
}
|
||||
|
||||
public bool IsDualWield =>
|
||||
_player.Controller?.Motion.InterpretedState.CurrentStyle
|
||||
== CombatInputPlanner.DualWieldCombatStyle;
|
||||
|
||||
public bool PlayerReadyForAttack
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_player.Controller is not { } controller)
|
||||
return false;
|
||||
var motion = controller.Motion.InterpretedState;
|
||||
return CombatInputPlanner.PlayerInReadyPositionForAttack(
|
||||
_combat.CurrentMode,
|
||||
motion.CurrentStyle,
|
||||
motion.ForwardCommand);
|
||||
}
|
||||
}
|
||||
|
||||
public bool AutoRepeatAttack => _settings.AutoRepeatAttack;
|
||||
|
||||
public bool CanStartAttack()
|
||||
{
|
||||
if (!_inWorld.IsInWorld)
|
||||
return false;
|
||||
|
||||
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
|
||||
{
|
||||
_feedback.Show("Enter melee or missile combat first");
|
||||
Console.WriteLine(
|
||||
"combat: attack ignored; not in melee/missile combat mode");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
|
||||
{
|
||||
_feedback.Show("No monster target");
|
||||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
if (!CanStartAttack()
|
||||
|| _session.CurrentSession is not { } session
|
||||
|| _targets.SelectedObjectId is not { } target)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
power = Math.Clamp(power, 0f, 1f);
|
||||
if (_combat.CurrentMode == CombatMode.Missile)
|
||||
{
|
||||
session.SendMissileAttack(target, height, power);
|
||||
Console.WriteLine(
|
||||
$"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
|
||||
}
|
||||
else
|
||||
{
|
||||
session.SendMeleeAttack(target, height, power);
|
||||
Console.WriteLine(
|
||||
$"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SendCancelAttack() =>
|
||||
_session.CurrentSession?.SendCancelAttack();
|
||||
|
||||
public void PrepareAttackRequest()
|
||||
{
|
||||
if (_player.Controller is not { } controller
|
||||
|| !controller.PrepareForAttackRequest())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_outbound.TrySendMovement(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
}
|
||||
}
|
||||
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal file
214
src/AcDream.App/Combat/LiveCombatModeCommandController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable
|
|||
private bool _wbDiagNoticePrinted;
|
||||
private bool _wasEnabled;
|
||||
|
||||
/// <summary>Most recent immutable report line, for explicit automation checkpoints.</summary>
|
||||
public string? LastReport { get; private set; }
|
||||
|
||||
public FrameProfiler()
|
||||
{
|
||||
_stageUs = new FrameStatsBuffer[StageCount];
|
||||
|
|
@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable
|
|||
int gc0 = GC.CollectionCount(0) - _gc0Base;
|
||||
int gc1 = GC.CollectionCount(1) - _gc1Base;
|
||||
int gc2 = GC.CollectionCount(2) - _gc2Base;
|
||||
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
|
||||
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
|
||||
LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs,
|
||||
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs);
|
||||
Console.WriteLine(LastReport);
|
||||
_lastReportTicks = nowTicks;
|
||||
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
|
||||
_framesInWindow = 0;
|
||||
|
|
|
|||
145
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
145
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using Silk.NET.OpenGL;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Render-thread owner for diagnostic captures of the complete default
|
||||
/// framebuffer. Requests may be made while retained UI ticks; capture occurs
|
||||
/// later in the same frame after world, retained UI, and optional ImGui draw.
|
||||
/// </summary>
|
||||
internal sealed class FrameScreenshotController
|
||||
{
|
||||
private enum CaptureState
|
||||
{
|
||||
Pending,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
private sealed record CaptureStatus(CaptureState State, string? Error = null);
|
||||
|
||||
private readonly Func<int, int, byte[]> _readRgba;
|
||||
private readonly string _directory;
|
||||
private readonly Action<string> _log;
|
||||
private readonly Queue<string> _pending = new();
|
||||
private readonly Dictionary<string, CaptureStatus> _status =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public FrameScreenshotController(
|
||||
GL gl,
|
||||
string directory,
|
||||
Action<string>? log = null)
|
||||
: this((width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
}
|
||||
|
||||
internal FrameScreenshotController(
|
||||
Func<int, int, byte[]> readRgba,
|
||||
string directory,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
|
||||
_directory = string.IsNullOrWhiteSpace(directory)
|
||||
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
|
||||
: Path.GetFullPath(directory);
|
||||
_log = log ?? (_ => { });
|
||||
}
|
||||
|
||||
public bool TryRequest(string name, out string error)
|
||||
{
|
||||
if (!AutomationArtifactName.TryValidate(name, out error))
|
||||
return false;
|
||||
|
||||
if (_status.TryGetValue(name, out CaptureStatus? status))
|
||||
{
|
||||
if (status.State != CaptureState.Failed)
|
||||
return true;
|
||||
error = status.Error ?? $"screenshot '{name}' failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
_status.Add(name, new CaptureStatus(CaptureState.Pending));
|
||||
_pending.Enqueue(name);
|
||||
_log($"[world-gate] screenshot-request name={name}");
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsComplete(string name) =>
|
||||
_status.TryGetValue(name, out CaptureStatus? status)
|
||||
&& status.State == CaptureState.Complete;
|
||||
|
||||
/// <summary>Captures at most one queued image on the current GL thread.</summary>
|
||||
public bool CapturePending(int width, int height)
|
||||
{
|
||||
if (_pending.Count == 0)
|
||||
return false;
|
||||
|
||||
string name = _pending.Dequeue();
|
||||
try
|
||||
{
|
||||
if (width <= 0 || height <= 0)
|
||||
throw new InvalidOperationException($"invalid framebuffer size {width}x{height}");
|
||||
|
||||
byte[] pixels = _readRgba(width, height);
|
||||
int expected = checked(width * height * 4);
|
||||
if (pixels.Length != expected)
|
||||
throw new InvalidOperationException(
|
||||
$"framebuffer read returned {pixels.Length} bytes; expected {expected}");
|
||||
|
||||
byte[] flipped = FlipRows(pixels, width, height);
|
||||
Directory.CreateDirectory(_directory);
|
||||
string path = Path.Combine(_directory, name + ".png");
|
||||
string temporaryPath = path + ".tmp";
|
||||
using (Image<Rgba32> image = Image.LoadPixelData<Rgba32>(flipped, width, height))
|
||||
image.SaveAsPng(temporaryPath);
|
||||
File.Move(temporaryPath, path, overwrite: true);
|
||||
|
||||
_status[name] = new CaptureStatus(CaptureState.Complete);
|
||||
_log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
string message = $"screenshot '{name}' failed: {exception.Message}";
|
||||
_status[name] = new CaptureStatus(CaptureState.Failed, message);
|
||||
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] FlipRows(byte[] pixels, int width, int height)
|
||||
{
|
||||
int stride = checked(width * 4);
|
||||
byte[] flipped = new byte[pixels.Length];
|
||||
for (int row = 0; row < height; row++)
|
||||
{
|
||||
System.Buffer.BlockCopy(
|
||||
pixels,
|
||||
row * stride,
|
||||
flipped,
|
||||
(height - 1 - row) * stride,
|
||||
stride);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
private static unsafe byte[] ReadDefaultFramebuffer(GL gl, int width, int height)
|
||||
{
|
||||
byte[] pixels = new byte[checked(width * height * 4)];
|
||||
fixed (byte* pointer = pixels)
|
||||
{
|
||||
gl.ReadPixels(
|
||||
0,
|
||||
0,
|
||||
(uint)width,
|
||||
(uint)height,
|
||||
PixelFormat.Rgba,
|
||||
PixelType.UnsignedByte,
|
||||
pointer);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.UI.Testing;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
internal static class AutomationArtifactName
|
||||
{
|
||||
public static bool TryValidate(string? name, out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 80)
|
||||
{
|
||||
error = "artifact name must contain 1-80 characters";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (char c in name)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_')
|
||||
{
|
||||
error = $"artifact name '{name}' may contain only letters, digits, '-' and '_'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record WorldLifecycleResourceSnapshot(
|
||||
int LoadedLandblocks,
|
||||
int WorldEntities,
|
||||
int AnimatedEntities,
|
||||
int VisibleLandblocks,
|
||||
int TotalLandblocks,
|
||||
int LiveEntities,
|
||||
int MaterializedLiveEntities,
|
||||
int PendingLiveTeardowns,
|
||||
int PendingLandblockRetirements,
|
||||
int ParticleEmitters,
|
||||
int Particles,
|
||||
int ParticleBindings,
|
||||
int ParticleOwners,
|
||||
int EffectOwners,
|
||||
int LightOwners,
|
||||
int ScriptOwners,
|
||||
int ActiveScripts,
|
||||
int MeshRenderData,
|
||||
int MeshAtlasArrays,
|
||||
long MeshEstimatedBytes,
|
||||
int StagedMeshUploads,
|
||||
long StagedMeshBytes,
|
||||
long TrackedGpuBytes,
|
||||
int TrackedGpuBuffers,
|
||||
int TrackedGpuTextures,
|
||||
int OwnedCompositeTextures,
|
||||
int CompositeTextureOwners,
|
||||
int ActiveParticleTextures,
|
||||
int ParticleTextureOwners,
|
||||
int CompositeWarmupPending,
|
||||
long ManagedBytes,
|
||||
long ManagedCommittedBytes,
|
||||
double Fps,
|
||||
double FrameMilliseconds,
|
||||
string? LastFrameProfile);
|
||||
|
||||
internal sealed record WorldLifecycleCheckpoint(
|
||||
int Sequence,
|
||||
string Name,
|
||||
DateTime TimestampUtc,
|
||||
int ProcessId,
|
||||
WorldRevealLifecycleSnapshot Reveal,
|
||||
WorldLifecycleResourceSnapshot Resources);
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only runtime seam used by production retained-UI scripts. It
|
||||
/// writes one structured checkpoint at explicit script edges and delegates GL
|
||||
/// capture to the render-thread screenshot owner.
|
||||
/// </summary>
|
||||
internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly Func<WorldRevealLifecycleSnapshot> _getReveal;
|
||||
private readonly Func<int> _getPortalMaterializationCount;
|
||||
private readonly Func<WorldLifecycleResourceSnapshot> _captureResources;
|
||||
private readonly FrameScreenshotController _screenshots;
|
||||
private readonly string _artifactDirectory;
|
||||
private readonly Action<string> _log;
|
||||
private int _sequence;
|
||||
|
||||
public WorldLifecycleAutomationController(
|
||||
Func<WorldRevealLifecycleSnapshot> getReveal,
|
||||
Func<int> getPortalMaterializationCount,
|
||||
Func<WorldLifecycleResourceSnapshot> captureResources,
|
||||
FrameScreenshotController screenshots,
|
||||
string artifactDirectory,
|
||||
Action<string>? log = null)
|
||||
{
|
||||
_getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal));
|
||||
_getPortalMaterializationCount = getPortalMaterializationCount
|
||||
?? throw new ArgumentNullException(nameof(getPortalMaterializationCount));
|
||||
_captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources));
|
||||
_screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots));
|
||||
_artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory)
|
||||
? throw new ArgumentException("An automation artifact directory is required.", nameof(artifactDirectory))
|
||||
: Path.GetFullPath(artifactDirectory);
|
||||
_log = log ?? (_ => { });
|
||||
}
|
||||
|
||||
public bool IsWorldReady => _getReveal().IsReady;
|
||||
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
|
||||
public int PortalMaterializationCount => _getPortalMaterializationCount();
|
||||
|
||||
public bool TryWriteCheckpoint(string name, out string error)
|
||||
{
|
||||
if (!AutomationArtifactName.TryValidate(name, out error))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_artifactDirectory);
|
||||
var checkpoint = new WorldLifecycleCheckpoint(
|
||||
Sequence: checked(++_sequence),
|
||||
Name: name,
|
||||
TimestampUtc: DateTime.UtcNow,
|
||||
ProcessId: Environment.ProcessId,
|
||||
Reveal: _getReveal(),
|
||||
Resources: _captureResources());
|
||||
string json = JsonSerializer.Serialize(checkpoint, JsonOptions);
|
||||
string timelinePath = Path.Combine(_artifactDirectory, "world-lifecycle.checkpoints.jsonl");
|
||||
File.AppendAllText(timelinePath, json + Environment.NewLine);
|
||||
File.WriteAllText(
|
||||
Path.Combine(_artifactDirectory, $"checkpoint-{name}.json"),
|
||||
json + Environment.NewLine);
|
||||
_log($"[world-gate] checkpoint name={name} sequence={checkpoint.Sequence} path={timelinePath}");
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = $"checkpoint '{name}' failed: {exception.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryRequestScreenshot(string name, out string error) =>
|
||||
_screenshots.TryRequest(name, out error);
|
||||
|
||||
public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name);
|
||||
}
|
||||
455
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
455
src/AcDream.App/Input/CameraPointerInputController.cs
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow raw-pointer surface. The production adapter keeps the Silk event
|
||||
/// accessor out of the camera policy and lets failure tests exercise an event
|
||||
/// add/remove without implementing Silk's complete mouse contract.
|
||||
/// </summary>
|
||||
internal interface IRawPointerSurface
|
||||
{
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
}
|
||||
|
||||
internal sealed class SilkRawPointerSurface : IRawPointerSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private Action<Vector2>? _callback;
|
||||
private readonly Action<IMouse, Vector2> _mouseMove;
|
||||
|
||||
public SilkRawPointerSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_mouseMove = OnMouseMove;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (_callback is not null)
|
||||
throw new InvalidOperationException("The raw mouse surface is already attached.");
|
||||
|
||||
// Publish before invoking the custom event accessor: an accessor may
|
||||
// perform its side effect and then throw, in which case rollback still
|
||||
// has the exact delegate needed to remove it.
|
||||
_callback = callback;
|
||||
_mouse.MouseMove += _mouseMove;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
if (!ReferenceEquals(_callback, callback))
|
||||
return;
|
||||
|
||||
_mouse.MouseMove -= _mouseMove;
|
||||
_callback = null;
|
||||
}
|
||||
|
||||
private void OnMouseMove(IMouse _, Vector2 position) =>
|
||||
_callback?.Invoke(position);
|
||||
}
|
||||
|
||||
internal interface IPointerCursorModeTarget
|
||||
{
|
||||
CursorMode CursorMode { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
|
||||
: IPointerCursorModeTarget
|
||||
{
|
||||
private readonly IMouse _mouse = mouse
|
||||
?? throw new ArgumentNullException(nameof(mouse));
|
||||
|
||||
public CursorMode CursorMode
|
||||
{
|
||||
get => _mouse.Cursor.CursorMode;
|
||||
set => _mouse.Cursor.CursorMode = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IPointerSensitivityCommands
|
||||
{
|
||||
string AdjustSensitivity(float factor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
|
||||
/// previously lived in GameWindow. Physical event removal is retriable; logical
|
||||
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
|
||||
/// re-enter gameplay while shutdown is closing the live session.
|
||||
/// </summary>
|
||||
internal sealed class CameraPointerInputController
|
||||
: IDisposable, IPointerSensitivityCommands
|
||||
{
|
||||
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
|
||||
private readonly IPointerCursorModeTarget _cursor;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly LocalPlayerModeState _playerMode;
|
||||
private readonly CameraController _camera;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly IMouseSource _mouse;
|
||||
private readonly PointerPositionState _pointer;
|
||||
private readonly IInputMonotonicClock _clock;
|
||||
private readonly Action<Vector2> _mouseMoved;
|
||||
private readonly Action<bool> _cameraModeChanged;
|
||||
private readonly bool[] _surfaceAttached;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private GameplayInputFrameController? _gameplayFrame;
|
||||
private bool _cameraAttached;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _flySensitivity = 1f;
|
||||
private float _orbitSensitivity = 1f;
|
||||
|
||||
public CameraPointerInputController(
|
||||
IReadOnlyList<IRawPointerSurface> surfaces,
|
||||
IPointerCursorModeTarget cursor,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
_surfaces = surfaces ?? throw new ArgumentNullException(nameof(surfaces));
|
||||
if (_surfaces.Any(static surface => surface is null))
|
||||
throw new ArgumentException("Raw pointer surfaces cannot contain null.", nameof(surfaces));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_surfaceAttached = new bool[_surfaces.Count];
|
||||
_mouseMoved = OnMouseMoved;
|
||||
_cameraModeChanged = OnCameraModeChanged;
|
||||
}
|
||||
|
||||
public static CameraPointerInputController Create(
|
||||
IReadOnlyList<IMouse> mice,
|
||||
HostQuiescenceGate quiescence,
|
||||
IInputCaptureSource capture,
|
||||
LocalPlayerModeState playerMode,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMouseSource mouse,
|
||||
PointerPositionState pointer,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mice);
|
||||
if (mice.Count == 0)
|
||||
throw new ArgumentException("At least one mouse is required.", nameof(mice));
|
||||
|
||||
return new CameraPointerInputController(
|
||||
mice.Select(static candidate =>
|
||||
(IRawPointerSurface)new SilkRawPointerSurface(candidate)).ToArray(),
|
||||
new SilkPointerCursorModeTarget(mice[0]),
|
||||
quiescence,
|
||||
capture,
|
||||
playerMode,
|
||||
camera,
|
||||
chase,
|
||||
mouse,
|
||||
pointer,
|
||||
clock);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_cameraAttached && _surfaceAttached.All(static attached => !attached);
|
||||
|
||||
public float ActiveSensitivity
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
return _chase.Sensitivity;
|
||||
if (_camera.IsFlyMode)
|
||||
return _flySensitivity;
|
||||
return _orbitSensitivity;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RmbOrbitHeld => _chase.RmbOrbitHeld;
|
||||
|
||||
public void AttachRaw()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Raw pointer attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < _surfaces.Count; i++)
|
||||
{
|
||||
_surfaceAttached[i] = true;
|
||||
_surfaces[i].AddMouseMove(_mouseMoved);
|
||||
}
|
||||
|
||||
_cameraAttached = true;
|
||||
_camera.ModeChanged += _cameraModeChanged;
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Raw pointer registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Raw pointer registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Raw pointer registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the deliberate two-phase composition edge. Binding the frame
|
||||
/// owner never resubscribes or changes event priority.
|
||||
/// </summary>
|
||||
public void BindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (_gameplayFrame is not null
|
||||
&& !ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The raw pointer owner is already bound to another gameplay frame.");
|
||||
}
|
||||
|
||||
_gameplayFrame = gameplayFrame;
|
||||
}
|
||||
|
||||
public void UnbindGameplayFrame(GameplayInputFrameController gameplayFrame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gameplayFrame);
|
||||
if (ReferenceEquals(_gameplayFrame, gameplayFrame))
|
||||
_gameplayFrame = null;
|
||||
}
|
||||
|
||||
public void HandleFocusChanged(bool focused)
|
||||
{
|
||||
if (!focused)
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
}
|
||||
|
||||
public void HandleScroll(InputAction action)
|
||||
{
|
||||
float direction = action switch
|
||||
{
|
||||
InputAction.ScrollUp => 1f,
|
||||
InputAction.ScrollDown => -1f,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(action)),
|
||||
};
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is not null)
|
||||
_chase.Retail.AdjustDistance(-direction * 0.8f);
|
||||
else
|
||||
_chase.Legacy?.AdjustDistance(-direction * 0.8f);
|
||||
}
|
||||
else if (!_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Orbit.Distance = Math.Clamp(
|
||||
_camera.Orbit.Distance - direction * 20f,
|
||||
50f,
|
||||
2000f);
|
||||
}
|
||||
}
|
||||
|
||||
public string AdjustSensitivity(float factor)
|
||||
{
|
||||
string mode;
|
||||
float current;
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode)
|
||||
{
|
||||
mode = "Chase";
|
||||
current = _chase.Sensitivity;
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
mode = "Fly";
|
||||
current = _flySensitivity;
|
||||
}
|
||||
else
|
||||
{
|
||||
mode = "Orbit";
|
||||
current = _orbitSensitivity;
|
||||
}
|
||||
|
||||
float next = MathF.Min(3f, MathF.Max(0.005f, current * factor));
|
||||
if (mode == "Chase")
|
||||
_chase.Sensitivity = next;
|
||||
else if (mode == "Fly")
|
||||
_flySensitivity = next;
|
||||
else
|
||||
_orbitSensitivity = next;
|
||||
|
||||
return $"{mode} sens {next:F3}x";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediate callback cutoff used before the live-session close. The
|
||||
/// gameplay mouse-look transition is retained until the session has
|
||||
/// retired so it cannot publish one last movement packet.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
_chase.RmbOrbitHeld = false;
|
||||
}
|
||||
|
||||
/// <summary>Release presentation state only after live-session retirement.</summary>
|
||||
public void ReleaseMouseLookAfterSessionRetirement() =>
|
||||
_gameplayFrame?.EndMouseLook();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnMouseMoved(Vector2 position) =>
|
||||
_quiescence.Invoke(() => ProcessMouseMove(position));
|
||||
|
||||
private void ProcessMouseMove(Vector2 position)
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_capture.WantCaptureMouse)
|
||||
{
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
return;
|
||||
}
|
||||
|
||||
float dx = position.X - _pointer.X;
|
||||
float dy = position.Y - _pointer.Y;
|
||||
|
||||
if (_playerMode.IsPlayerMode && _camera.IsChaseMode
|
||||
&& _chase.Legacy is not null)
|
||||
{
|
||||
float sensitivity = _chase.Sensitivity;
|
||||
if (_gameplayFrame?.MouseLookActive == true)
|
||||
{
|
||||
_gameplayFrame.QueueRawMouseDelta(dx, dy);
|
||||
}
|
||||
else if (_chase.RmbOrbitHeld)
|
||||
{
|
||||
if (CameraDiagnostics.UseRetailChaseCamera
|
||||
&& _chase.Retail is not null)
|
||||
{
|
||||
var (filteredDx, filteredDy) = _chase.Retail.FilterMouseDelta(
|
||||
rawX: dx,
|
||||
rawY: dy,
|
||||
weight: 0.5f,
|
||||
nowSec: _clock.NowSeconds);
|
||||
_chase.Retail.YawOffset -= filteredDx * 0.004f * sensitivity;
|
||||
_chase.Retail.AdjustPitch(filteredDy * 0.003f * sensitivity);
|
||||
}
|
||||
else
|
||||
{
|
||||
_chase.Legacy.YawOffset -= dx * 0.004f * sensitivity;
|
||||
_chase.Legacy.AdjustPitch(dy * 0.003f * sensitivity);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (_camera.IsFlyMode)
|
||||
{
|
||||
_camera.Fly.Look(dx * _flySensitivity, dy * _flySensitivity);
|
||||
}
|
||||
else if (_mouse.IsHeld(MouseButton.Left))
|
||||
{
|
||||
_camera.Orbit.Yaw -= dx * 0.005f * _orbitSensitivity;
|
||||
_camera.Orbit.Pitch = Math.Clamp(
|
||||
_camera.Orbit.Pitch + dy * 0.005f * _orbitSensitivity,
|
||||
0.1f,
|
||||
1.5f);
|
||||
}
|
||||
|
||||
_pointer.X = position.X;
|
||||
_pointer.Y = position.Y;
|
||||
}
|
||||
|
||||
private void OnCameraModeChanged(bool _) =>
|
||||
_quiescence.Invoke(ApplyCursorForCameraMode);
|
||||
|
||||
private void ApplyCursorForCameraMode()
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
if (_gameplayFrame?.MouseLookActive == true && !_camera.IsChaseMode)
|
||||
_gameplayFrame.EndMouseLook();
|
||||
|
||||
_cursor.CursorMode = _camera.IsFlyMode
|
||||
? CursorMode.Raw
|
||||
: CursorMode.Normal;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>
|
||||
{
|
||||
new("camera mode", RemoveCameraMode),
|
||||
};
|
||||
for (int i = _surfaces.Count - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"raw mouse {index}",
|
||||
() => RemoveSurface(index)));
|
||||
}
|
||||
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("raw pointer callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveCameraMode()
|
||||
{
|
||||
if (!_cameraAttached)
|
||||
return;
|
||||
_camera.ModeChanged -= _cameraModeChanged;
|
||||
_cameraAttached = false;
|
||||
}
|
||||
|
||||
private void RemoveSurface(int index)
|
||||
{
|
||||
if (!_surfaceAttached[index])
|
||||
return;
|
||||
_surfaces[index].RemoveMouseMove(_mouseMoved);
|
||||
_surfaceAttached[index] = false;
|
||||
}
|
||||
}
|
||||
79
src/AcDream.App/Input/DispatcherCameraInputSource.cs
Normal file
79
src/AcDream.App/Input/DispatcherCameraInputSource.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal readonly record struct FlyCameraInput(
|
||||
bool Forward,
|
||||
bool Left,
|
||||
bool Backward,
|
||||
bool Right,
|
||||
bool Up,
|
||||
bool Down,
|
||||
bool Boost);
|
||||
|
||||
internal readonly record struct ChaseCameraAdjustmentInput(
|
||||
bool ZoomIn,
|
||||
bool ZoomOut,
|
||||
bool Raise,
|
||||
bool Lower);
|
||||
|
||||
internal interface ICameraFrameInputSource
|
||||
{
|
||||
bool IsAvailable { get; }
|
||||
FlyCameraInput CaptureFly();
|
||||
ChaseCameraAdjustmentInput CaptureChaseAdjustment();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples held camera actions from the same semantic dispatcher used by
|
||||
/// gameplay movement. Binding is a one-time startup edge.
|
||||
/// </summary>
|
||||
internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
|
||||
{
|
||||
private InputDispatcher? _dispatcher;
|
||||
|
||||
public bool IsAvailable => _dispatcher is not null;
|
||||
|
||||
public void Bind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher))
|
||||
throw new InvalidOperationException(
|
||||
"The camera input source is already bound to another dispatcher.");
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public FlyCameraInput CaptureFly()
|
||||
{
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
||||
return new FlyCameraInput(
|
||||
dispatcher.IsActionHeld(InputAction.MovementForward),
|
||||
dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
|
||||
dispatcher.IsActionHeld(InputAction.MovementBackup),
|
||||
dispatcher.IsActionHeld(InputAction.MovementTurnRight),
|
||||
dispatcher.IsActionHeld(InputAction.MovementJump),
|
||||
dispatcher.IsActionHeld(InputAction.AcdreamFlyDown),
|
||||
dispatcher.IsActionHeld(InputAction.MovementRunLock));
|
||||
}
|
||||
|
||||
public ChaseCameraAdjustmentInput CaptureChaseAdjustment()
|
||||
{
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
||||
return new ChaseCameraAdjustmentInput(
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomIn),
|
||||
dispatcher.IsActionHeld(InputAction.CameraZoomOut),
|
||||
dispatcher.IsActionHeld(InputAction.CameraRaise),
|
||||
dispatcher.IsActionHeld(InputAction.CameraLower));
|
||||
}
|
||||
}
|
||||
91
src/AcDream.App/Input/DispatcherMovementInputSource.cs
Normal file
91
src/AcDream.App/Input/DispatcherMovementInputSource.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IMovementInputSource
|
||||
{
|
||||
MovementInput Capture();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns held movement sampling and the retail autorun latch. The input
|
||||
/// dispatcher remains the only keyboard/mouse-button state source.
|
||||
/// </summary>
|
||||
internal sealed class DispatcherMovementInputSource : IMovementInputSource
|
||||
{
|
||||
private readonly IInputCaptureSource? _capture;
|
||||
private InputDispatcher? _dispatcher;
|
||||
|
||||
public DispatcherMovementInputSource(IInputCaptureSource? capture = null) =>
|
||||
_capture = capture;
|
||||
|
||||
public bool AutoRunActive { get; private set; }
|
||||
public bool IsAvailable => _dispatcher is not null;
|
||||
|
||||
public void Bind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher))
|
||||
throw new InvalidOperationException(
|
||||
"The movement input source is already bound to another dispatcher.");
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public void Unbind(InputDispatcher dispatcher)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
if (ReferenceEquals(_dispatcher, dispatcher))
|
||||
_dispatcher = null;
|
||||
}
|
||||
|
||||
public MovementInput Capture()
|
||||
{
|
||||
// Devtools owns the whole gameplay keyboard while active, including
|
||||
// a latched autorun. Retained chat owns physical key state only;
|
||||
// retail's autorun latch continues until an explicit cancel action.
|
||||
if (_capture?.DevToolsWantCaptureKeyboard == true)
|
||||
return default;
|
||||
|
||||
if (_dispatcher is not { } dispatcher)
|
||||
return default;
|
||||
|
||||
bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode);
|
||||
bool forward = dispatcher.IsActionHeld(InputAction.MovementForward);
|
||||
return new MovementInput(
|
||||
Forward: forward || AutoRunActive,
|
||||
Backward: dispatcher.IsActionHeld(InputAction.MovementBackup),
|
||||
StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft),
|
||||
StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight),
|
||||
TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
|
||||
TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight),
|
||||
Run: !walking,
|
||||
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the press-only autorun policy at the same point in the semantic
|
||||
/// action pipeline as the former GameWindow body.
|
||||
/// </summary>
|
||||
/// <returns>True when the action is fully consumed.</returns>
|
||||
public bool HandlePressedAction(InputAction action)
|
||||
{
|
||||
if (action == InputAction.MovementRunLock)
|
||||
{
|
||||
AutoRunActive = !AutoRunActive;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (AutoRunActive && action is (
|
||||
InputAction.MovementBackup
|
||||
or InputAction.MovementStop
|
||||
or InputAction.MovementStrafeLeft
|
||||
or InputAction.MovementStrafeRight))
|
||||
{
|
||||
AutoRunActive = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void ResetSession() => AutoRunActive = false;
|
||||
}
|
||||
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal file
316
src/AcDream.App/Input/GameplayInputActionRouter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal file
210
src/AcDream.App/Input/GameplayInputCommandController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
89
src/AcDream.App/Input/GameplayInputFrameController.cs
Normal file
89
src/AcDream.App/Input/GameplayInputFrameController.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface ICombatInputFrameController
|
||||
{
|
||||
void Tick();
|
||||
void HandleMovementInput(InputAction action, ActivationType activation);
|
||||
bool HandleInputAction(InputAction action, ActivationType activation);
|
||||
}
|
||||
|
||||
internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController
|
||||
{
|
||||
private readonly CombatAttackController _owner;
|
||||
|
||||
public CombatAttackInputFrameAdapter(CombatAttackController owner) =>
|
||||
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
|
||||
public void Tick() => _owner.Tick();
|
||||
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation) =>
|
||||
_owner.HandleMovementInput(action, activation);
|
||||
|
||||
public bool HandleInputAction(InputAction action, ActivationType activation) =>
|
||||
_owner.HandleInputAction(action, activation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
|
||||
/// mouse-look sample, and combat attack intent.
|
||||
/// </summary>
|
||||
internal sealed class GameplayInputFrameController
|
||||
: IGameplayInputFramePhase,
|
||||
AcDream.App.Streaming.ILocalPlayerTeleportInputLifetime
|
||||
{
|
||||
private readonly InputDispatcher? _dispatcher;
|
||||
private readonly DispatcherMovementInputSource _movement;
|
||||
private readonly IMouseLookInputFrameController? _mouseLook;
|
||||
private readonly ICombatInputFrameController _combat;
|
||||
|
||||
public GameplayInputFrameController(
|
||||
InputDispatcher? dispatcher,
|
||||
DispatcherMovementInputSource movement,
|
||||
IMouseLookInputFrameController? mouseLook,
|
||||
ICombatInputFrameController combat)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
_mouseLook = mouseLook;
|
||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
}
|
||||
|
||||
public bool MouseLookActive => _mouseLook?.Active == true;
|
||||
|
||||
public void Tick(UpdateFrameTiming timing)
|
||||
{
|
||||
_ = timing;
|
||||
_dispatcher?.Tick();
|
||||
_mouseLook?.Tick();
|
||||
_combat.Tick();
|
||||
}
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
|
||||
_mouseLook?.HandlePointerAction(action, activation) == true;
|
||||
|
||||
public bool HandleCombatAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
// ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0 aborts
|
||||
// automatic combat before the movement command enters the interpreter.
|
||||
_combat.HandleMovementInput(action, activation);
|
||||
return _combat.HandleInputAction(action, activation);
|
||||
}
|
||||
|
||||
public bool HandlePressedMovementAction(InputAction action) =>
|
||||
_movement.HandlePressedAction(action);
|
||||
|
||||
public void QueueRawMouseDelta(float dx, float dy) =>
|
||||
_mouseLook?.QueueRawDelta(dx, dy);
|
||||
|
||||
public void EndMouseLook() => _mouseLook?.EndForLifecycle();
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
_mouseLook?.ResetSession();
|
||||
_movement.ResetSession();
|
||||
}
|
||||
}
|
||||
54
src/AcDream.App/Input/InputCaptureSources.cs
Normal file
54
src/AcDream.App/Input/InputCaptureSources.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IInputCaptureSource
|
||||
{
|
||||
bool WantCaptureMouse { get; }
|
||||
bool WantCaptureKeyboard { get; }
|
||||
bool DevToolsWantCaptureKeyboard { get; }
|
||||
}
|
||||
|
||||
internal sealed class DevToolsInputCaptureSource
|
||||
{
|
||||
private readonly bool _enabled;
|
||||
|
||||
public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled;
|
||||
|
||||
public bool WantCaptureMouse =>
|
||||
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
|
||||
|
||||
public bool WantCaptureKeyboard =>
|
||||
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
|
||||
}
|
||||
|
||||
internal sealed class RetainedUiInputCaptureSlot
|
||||
{
|
||||
public UiRoot? Root { get; set; }
|
||||
|
||||
public bool WantCaptureMouse => Root?.WantsMouse ?? false;
|
||||
public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false;
|
||||
}
|
||||
|
||||
internal sealed class CompositeInputCaptureSource : IInputCaptureSource
|
||||
{
|
||||
private readonly DevToolsInputCaptureSource _devTools;
|
||||
private readonly RetainedUiInputCaptureSlot _retained;
|
||||
|
||||
public CompositeInputCaptureSource(
|
||||
DevToolsInputCaptureSource devTools,
|
||||
RetainedUiInputCaptureSlot retained)
|
||||
{
|
||||
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
|
||||
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
|
||||
}
|
||||
|
||||
public bool WantCaptureMouse =>
|
||||
_devTools.WantCaptureMouse || _retained.WantCaptureMouse;
|
||||
|
||||
public bool WantCaptureKeyboard =>
|
||||
DevToolsWantCaptureKeyboard || _retained.WantCaptureKeyboard;
|
||||
|
||||
public bool DevToolsWantCaptureKeyboard =>
|
||||
_devTools.WantCaptureKeyboard;
|
||||
}
|
||||
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
75
src/AcDream.App/Input/LocalPlayerAnimationController.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the local player's sequencer root-motion and deferred-hook seams.
|
||||
/// The movement controller borrows these methods when player mode is entered;
|
||||
/// no animation lifetime is stored in the window host.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerAnimationController
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||
private readonly LiveEntityAnimationPresenter _presenter;
|
||||
private readonly AnimationHookFrameQueue _hooks;
|
||||
|
||||
public LocalPlayerAnimationController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
|
||||
LiveEntityAnimationPresenter presenter,
|
||||
AnimationHookFrameQueue hooks)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||
_presenter = presenter ?? throw new ArgumentNullException(nameof(presenter));
|
||||
_hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
|
||||
}
|
||||
|
||||
public void AdvanceRoot(float deltaSeconds, MotionDeltaFrame output)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
output.Reset();
|
||||
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
|
||||
|| !_animations.TryGetValue(entity.Id, out var animation)
|
||||
|| animation.Sequencer is not { } sequencer
|
||||
|| !_liveEntities.ShouldAdvanceRootRuntime(playerGuid)
|
||||
|| _liveEntities.IsHidden(playerGuid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord record))
|
||||
_presenter.PrepareAnimation(record, animation);
|
||||
|
||||
Frame rootFrame = animation.RootMotionScratch;
|
||||
rootFrame.Origin = System.Numerics.Vector3.Zero;
|
||||
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
|
||||
animation.PreparedSequenceFrames = animation.CaptureSequenceFrames(
|
||||
sequencer.Advance(deltaSeconds, rootFrame));
|
||||
animation.SequenceAdvancedBeforeAnimationPass = true;
|
||||
|
||||
output.Origin = rootFrame.Origin;
|
||||
output.Orientation = rootFrame.Orientation;
|
||||
}
|
||||
|
||||
public void CaptureHooks()
|
||||
{
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity)
|
||||
&& _animations.TryGetValue(entity.Id, out var animation)
|
||||
&& animation.Sequencer is { } sequencer)
|
||||
{
|
||||
_hooks.Capture(animation.Entity.Id, sequencer);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
Normal file
122
src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using AcDream.App.Net;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Focused runtime seam for the local player's retail object and command
|
||||
/// phases. The frame controller owns ordering; this seam resolves the current
|
||||
/// session-scoped owners without retaining callbacks into the window host.
|
||||
/// </summary>
|
||||
internal interface ILocalPlayerPresentationRuntime
|
||||
{
|
||||
bool CanPresentPlayer { get; }
|
||||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime
|
||||
{
|
||||
uint ResolveLocalEntityId();
|
||||
void HandleTargeting();
|
||||
bool IsHidden { get; }
|
||||
RetailObjectClockDisposition ObjectClockDisposition { get; }
|
||||
void Project(PlayerMovementController controller, MovementResult movement, bool hidden);
|
||||
void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden);
|
||||
void SendPostNetwork(PlayerMovementController controller, bool hidden);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production local-player frame runtime composed from canonical typed owners.
|
||||
/// </summary>
|
||||
internal sealed class LiveLocalPlayerFrameRuntime : ILocalPlayerFrameRuntime
|
||||
{
|
||||
private readonly CameraController _camera;
|
||||
private readonly ILocalPlayerModeSource _mode;
|
||||
private readonly ILocalPlayerControllerSource _controller;
|
||||
private readonly IChaseCameraSource _chase;
|
||||
private readonly DispatcherMovementInputSource _input;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly ILocalPlayerPhysicsHostSource _physicsHost;
|
||||
private readonly LocalPlayerProjectionController _projection;
|
||||
private readonly LocalPlayerOutboundController _outbound;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
|
||||
public LiveLocalPlayerFrameRuntime(
|
||||
CameraController camera,
|
||||
ILocalPlayerModeSource mode,
|
||||
ILocalPlayerControllerSource controller,
|
||||
IChaseCameraSource chase,
|
||||
DispatcherMovementInputSource input,
|
||||
IInputCaptureSource capture,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
ILocalPlayerPhysicsHostSource physicsHost,
|
||||
LocalPlayerProjectionController projection,
|
||||
LocalPlayerOutboundController outbound,
|
||||
ILiveWorldSessionSource session)
|
||||
{
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_physicsHost = physicsHost ?? throw new ArgumentNullException(nameof(physicsHost));
|
||||
_projection = projection ?? throw new ArgumentNullException(nameof(projection));
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
public bool CanPresentPlayer =>
|
||||
!_camera.IsFlyMode
|
||||
&& _mode.IsPlayerMode
|
||||
&& _controller.Controller is not null
|
||||
&& _chase.Legacy is not null
|
||||
&& _input.IsAvailable
|
||||
&& !_capture.DevToolsWantCaptureKeyboard;
|
||||
|
||||
public PlayerMovementController? Controller => _controller.Controller;
|
||||
|
||||
public uint ResolveLocalEntityId() =>
|
||||
_liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity)
|
||||
? entity.Id
|
||||
: 0u;
|
||||
|
||||
public void HandleTargeting() => _physicsHost.Host?.HandleTargetting();
|
||||
|
||||
public bool IsHidden => _liveEntities.IsHidden(_identity.ServerGuid);
|
||||
|
||||
public RetailObjectClockDisposition ObjectClockDisposition =>
|
||||
_liveEntities.GetRootObjectClockDisposition(_identity.ServerGuid);
|
||||
|
||||
public void Project(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden) =>
|
||||
_projection.Project(controller, movement, hidden);
|
||||
|
||||
public void SendPreNetwork(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden) =>
|
||||
_outbound.SendPreNetworkActions(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
movement,
|
||||
hidden);
|
||||
|
||||
public void SendPostNetwork(
|
||||
PlayerMovementController controller,
|
||||
bool hidden) =>
|
||||
_outbound.SendPostNetworkPosition(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
hidden);
|
||||
}
|
||||
|
|
@ -11,13 +11,18 @@ namespace AcDream.App.Input;
|
|||
/// </summary>
|
||||
public sealed class LocalPlayerOutboundController
|
||||
{
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
|
||||
private readonly IMovementTruthDiagnosticSink _diagnostic;
|
||||
|
||||
internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic)
|
||||
{
|
||||
_diagnostic = diagnostic
|
||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||
}
|
||||
|
||||
public LocalPlayerOutboundController(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
||||
{
|
||||
_diagnostic = diagnostic
|
||||
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController
|
|||
teleportSequence: session.TeleportSequence,
|
||||
forcePositionSequence: session.ForcePositionSequence,
|
||||
lastContact: contactByte);
|
||||
_diagnostic(
|
||||
_diagnostic.OnOutbound(
|
||||
"AP",
|
||||
sequence,
|
||||
movement,
|
||||
|
|
@ -121,6 +126,47 @@ public sealed class LocalPlayerOutboundController
|
|||
controller.SimTimeSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail ForcePosition acknowledgement sent immediately after the local
|
||||
/// body is blipped. Unlike the periodic post-network sender, this bypasses
|
||||
/// the elapsed/difference predicate but still requires a valid grounded
|
||||
/// canonical frame.
|
||||
/// </summary>
|
||||
internal void SendImmediatePosition(
|
||||
WorldSession? session,
|
||||
PlayerMovementController? controller)
|
||||
{
|
||||
if (session is null
|
||||
|| controller is null
|
||||
|| !controller.CanSendPositionEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!controller.TryGetOutboundPosition(out Position outboundPosition))
|
||||
return;
|
||||
uint cellId = outboundPosition.ObjCellId;
|
||||
Vector3 position = outboundPosition.Frame.Origin;
|
||||
Quaternion rotation = outboundPosition.Frame.Orientation;
|
||||
|
||||
uint sequence = session.NextGameActionSequence();
|
||||
byte[] body = AutonomousPosition.Build(
|
||||
gameActionSequence: sequence,
|
||||
cellId: cellId,
|
||||
position: position,
|
||||
rotation: rotation,
|
||||
instanceSequence: session.InstanceSequence,
|
||||
serverControlSequence: session.ServerControlSequence,
|
||||
teleportSequence: session.TeleportSequence,
|
||||
forcePositionSequence: session.ForcePositionSequence,
|
||||
lastContact: 1);
|
||||
session.SendGameAction(body);
|
||||
controller.NotePositionSent(
|
||||
outboundPosition,
|
||||
controller.ContactPlane,
|
||||
controller.SimTimeSeconds);
|
||||
}
|
||||
|
||||
public bool TrySendMovement(
|
||||
WorldSession? session,
|
||||
PlayerMovementController? controller,
|
||||
|
|
@ -152,7 +198,7 @@ public sealed class LocalPlayerOutboundController
|
|||
forcePositionSequence: session.ForcePositionSequence,
|
||||
contact: contactByte != 0,
|
||||
standingLongjump: false);
|
||||
_diagnostic(
|
||||
_diagnostic.OnOutbound(
|
||||
"MTS",
|
||||
sequence,
|
||||
movement,
|
||||
|
|
|
|||
|
|
@ -1,47 +1,77 @@
|
|||
using AcDream.Core.World;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface ILocalPlayerProjectionRuntime
|
||||
{
|
||||
WorldEntity? ResolveEntity();
|
||||
int LiveCenterX { get; }
|
||||
int LiveCenterY { get; }
|
||||
void SyncShadow(WorldEntity entity, uint cellId);
|
||||
void Rebucket(uint serverGuid, uint landblockId);
|
||||
bool IsCurrentVisibleProjection(WorldEntity entity);
|
||||
void SuspendShadow(WorldEntity entity);
|
||||
}
|
||||
|
||||
internal sealed class LiveLocalPlayerProjectionRuntime
|
||||
: ILocalPlayerProjectionRuntime
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly LocalPlayerShadowSynchronizer _shadow;
|
||||
|
||||
public LiveLocalPlayerProjectionRuntime(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveWorldOriginState origin,
|
||||
LocalPlayerShadowSynchronizer shadow)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
|
||||
}
|
||||
|
||||
public WorldEntity? ResolveEntity() =>
|
||||
_liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity)
|
||||
? entity
|
||||
: null;
|
||||
|
||||
public int LiveCenterX => _origin.CenterX;
|
||||
public int LiveCenterY => _origin.CenterY;
|
||||
|
||||
public void SyncShadow(WorldEntity entity, uint cellId) =>
|
||||
_shadow.Sync(entity, cellId);
|
||||
|
||||
public void Rebucket(uint serverGuid, uint landblockId) =>
|
||||
_liveEntities.RebucketLiveEntity(serverGuid, landblockId);
|
||||
|
||||
public bool IsCurrentVisibleProjection(WorldEntity entity) =>
|
||||
_shadow.IsCurrentVisibleProjection(entity);
|
||||
|
||||
public void SuspendShadow(WorldEntity entity) => _shadow.Suspend(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects the canonical local physics body into world rendering and spatial
|
||||
/// buckets without advancing it.
|
||||
/// </summary>
|
||||
public sealed class LocalPlayerProjectionController
|
||||
{
|
||||
private readonly Func<WorldEntity?> _resolveEntity;
|
||||
private readonly Func<int> _liveCenterX;
|
||||
private readonly Func<int> _liveCenterY;
|
||||
private readonly Action<WorldEntity, uint> _syncShadow;
|
||||
private readonly Action<uint, uint> _rebucket;
|
||||
private readonly Func<WorldEntity, bool> _isCurrentVisibleProjection;
|
||||
private readonly Action<WorldEntity> _suspendShadow;
|
||||
private readonly ILocalPlayerProjectionRuntime _runtime;
|
||||
|
||||
public LocalPlayerProjectionController(
|
||||
Func<WorldEntity?> resolveEntity,
|
||||
Func<int> liveCenterX,
|
||||
Func<int> liveCenterY,
|
||||
Action<WorldEntity, uint> syncShadow,
|
||||
Action<uint, uint> rebucket,
|
||||
Func<WorldEntity, bool> isCurrentVisibleProjection,
|
||||
Action<WorldEntity> suspendShadow)
|
||||
{
|
||||
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
|
||||
_liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
|
||||
_liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
|
||||
_syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
|
||||
_rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
|
||||
_isCurrentVisibleProjection = isCurrentVisibleProjection
|
||||
?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
|
||||
_suspendShadow = suspendShadow
|
||||
?? throw new ArgumentNullException(nameof(suspendShadow));
|
||||
}
|
||||
internal LocalPlayerProjectionController(ILocalPlayerProjectionRuntime runtime) =>
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
|
||||
public void Project(
|
||||
PlayerMovementController controller,
|
||||
MovementResult movement,
|
||||
bool hidden)
|
||||
{
|
||||
WorldEntity? entity = _resolveEntity();
|
||||
WorldEntity? entity = _runtime.ResolveEntity();
|
||||
if (entity is null)
|
||||
return;
|
||||
|
||||
|
|
@ -62,8 +92,8 @@ public sealed class LocalPlayerProjectionController
|
|||
else
|
||||
{
|
||||
System.Numerics.Vector3 position = controller.Position;
|
||||
int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f);
|
||||
int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f);
|
||||
int landblockX = _runtime.LiveCenterX + (int)Math.Floor(position.X / 192f);
|
||||
int landblockY = _runtime.LiveCenterY + (int)Math.Floor(position.Y / 192f);
|
||||
currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
|
|
@ -76,15 +106,15 @@ public sealed class LocalPlayerProjectionController
|
|||
// membership, then replaces collision rows only while the object still
|
||||
// owns a loaded cell. Rebucket is a synchronous callback boundary: it
|
||||
// can move this incarnation to pending or replace the GUID entirely.
|
||||
_rebucket(entity.ServerGuid, currentLandblock);
|
||||
_runtime.Rebucket(entity.ServerGuid, currentLandblock);
|
||||
if (hidden
|
||||
|| movement.CellId == 0
|
||||
|| !_isCurrentVisibleProjection(entity))
|
||||
|| !_runtime.IsCurrentVisibleProjection(entity))
|
||||
{
|
||||
_suspendShadow(entity);
|
||||
_runtime.SuspendShadow(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
_syncShadow(entity, movement.CellId);
|
||||
_runtime.SyncShadow(entity, movement.CellId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
src/AcDream.App/Input/LocalPlayerRuntimeState.cs
Normal file
123
src/AcDream.App/Input/LocalPlayerRuntimeState.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.App.Physics;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface ILocalPlayerIdentitySource
|
||||
{
|
||||
uint ServerGuid { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped identity slot shared by focused update owners.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerIdentityState : ILocalPlayerIdentitySource
|
||||
{
|
||||
public uint ServerGuid { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerControllerSource
|
||||
{
|
||||
PlayerMovementController? Controller { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local movement-controller slot. Player-mode lifecycle owns
|
||||
/// assignment; update and presentation owners receive the read-only seam.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerControllerSlot : ILocalPlayerControllerSource
|
||||
{
|
||||
public PlayerMovementController? Controller { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerPhysicsHostSource
|
||||
{
|
||||
EntityPhysicsHost? Host { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local physics-host slot. Player-mode lifecycle owns
|
||||
/// assignment; teleport and update owners receive the read-only seam.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerPhysicsHostSlot : ILocalPlayerPhysicsHostSource
|
||||
{
|
||||
public EntityPhysicsHost? Host { get; set; }
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerModeSource
|
||||
{
|
||||
bool IsPlayerMode { get; }
|
||||
bool ChaseModeEverEntered { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one mutable local presentation-mode slot. Checkpoint F transfers write
|
||||
/// ownership to PlayerModeController; streaming and camera owners only read it.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerModeState : ILocalPlayerModeSource
|
||||
{
|
||||
public bool IsPlayerMode { get; set; }
|
||||
public bool ChaseModeEverEntered { get; set; }
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
IsPlayerMode = false;
|
||||
ChaseModeEverEntered = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped server-authoritative movement skills. Player-description
|
||||
/// delivery and player-mode construction share this owner so rebuilding the
|
||||
/// local physics controller cannot fall back to stale defaults.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerSkillState
|
||||
{
|
||||
public int RunSkill { get; private set; } = -1;
|
||||
public int JumpSkill { get; private set; } = -1;
|
||||
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
|
||||
|
||||
public void Update(
|
||||
int runSkill,
|
||||
int jumpSkill,
|
||||
PlayerMovementController? controller)
|
||||
{
|
||||
if (runSkill >= 0)
|
||||
RunSkill = runSkill;
|
||||
if (jumpSkill >= 0)
|
||||
JumpSkill = jumpSkill;
|
||||
ApplyTo(controller);
|
||||
}
|
||||
|
||||
public bool ApplyTo(PlayerMovementController? controller)
|
||||
{
|
||||
if (controller is null || !IsComplete)
|
||||
return false;
|
||||
|
||||
controller.SetCharacterSkills(RunSkill, JumpSkill);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
RunSkill = -1;
|
||||
JumpSkill = -1;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IViewportAspectSource
|
||||
{
|
||||
float Aspect { get; }
|
||||
}
|
||||
|
||||
/// <summary>Framebuffer aspect published by the window host.</summary>
|
||||
internal sealed class ViewportAspectState : IViewportAspectSource
|
||||
{
|
||||
public float Aspect { get; private set; } = 16f / 9f;
|
||||
|
||||
public void Update(int width, int height)
|
||||
{
|
||||
if (width > 0 && height > 0)
|
||||
Aspect = width / (float)height;
|
||||
}
|
||||
}
|
||||
268
src/AcDream.App/Input/MouseLookController.cs
Normal file
268
src/AcDream.App/Input/MouseLookController.cs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IInputMonotonicClock
|
||||
{
|
||||
float NowSeconds { get; }
|
||||
}
|
||||
|
||||
internal sealed class EnvironmentInputMonotonicClock : IInputMonotonicClock
|
||||
{
|
||||
public float NowSeconds => (float)(Environment.TickCount64 / 1000.0);
|
||||
}
|
||||
|
||||
internal interface IPointerPositionSource
|
||||
{
|
||||
float X { get; }
|
||||
float Y { get; }
|
||||
}
|
||||
|
||||
internal sealed class PointerPositionState : IPointerPositionSource
|
||||
{
|
||||
public float X { get; set; }
|
||||
public float Y { get; set; }
|
||||
}
|
||||
|
||||
internal interface IMouseLookCursor
|
||||
{
|
||||
bool HasSavedMode { get; }
|
||||
void Hide();
|
||||
void Restore();
|
||||
}
|
||||
|
||||
internal interface IMouseLookInputFrameController
|
||||
{
|
||||
bool Active { get; }
|
||||
bool HandlePointerAction(InputAction action, ActivationType activation);
|
||||
void QueueRawDelta(float dx, float dy);
|
||||
void Tick();
|
||||
void EndAndRestoreCursor();
|
||||
void EndForLifecycle();
|
||||
void ResetSession();
|
||||
}
|
||||
|
||||
internal sealed class SilkMouseLookCursor : IMouseLookCursor
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private CursorMode? _savedMode;
|
||||
|
||||
public SilkMouseLookCursor(IMouse mouse) =>
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
|
||||
public bool HasSavedMode => _savedMode.HasValue;
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_savedMode = _mouse.Cursor.CursorMode;
|
||||
_mouse.Cursor.CursorMode = CursorMode.Hidden;
|
||||
}
|
||||
|
||||
public void Restore()
|
||||
{
|
||||
_mouse.Cursor.CursorMode = _savedMode ?? CursorMode.Normal;
|
||||
_savedMode = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current chase-camera input targets. GameWindow keeps compatibility
|
||||
/// properties over this one slot until checkpoint F moves camera ownership.
|
||||
/// </summary>
|
||||
internal interface IChaseCameraSource
|
||||
{
|
||||
ChaseCamera? Legacy { get; }
|
||||
RetailChaseCamera? Retail { get; }
|
||||
}
|
||||
|
||||
internal sealed class ChaseCameraInputState : IChaseCameraSource
|
||||
{
|
||||
public ChaseCamera? Legacy { get; set; }
|
||||
public RetailChaseCamera? Retail { get; set; }
|
||||
public float Sensitivity { get; set; } = 0.15f;
|
||||
public bool RmbOrbitHeld { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns instant mouse-look state, raw-sample timing/filtering, movement
|
||||
/// transition sends, and cursor capture across every exit path.
|
||||
/// </summary>
|
||||
internal sealed class MouseLookController : IMouseLookInputFrameController
|
||||
{
|
||||
private readonly IMouseSource _mouseSource;
|
||||
private readonly IPointerPositionSource _pointer;
|
||||
private readonly ILocalPlayerModeSource _playerMode;
|
||||
private readonly ILocalPlayerControllerSource _playerController;
|
||||
private readonly CameraController _camera;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly IMovementInputSource _movementInput;
|
||||
private readonly LocalPlayerOutboundController _outbound;
|
||||
private readonly ILiveWorldSessionSource _session;
|
||||
private readonly IMouseLookCursor _cursor;
|
||||
private readonly IInputMonotonicClock _clock;
|
||||
private readonly MouseLookState _state;
|
||||
private bool _lastWantCaptureMouse;
|
||||
|
||||
public MouseLookController(
|
||||
IMouseSource mouseSource,
|
||||
IPointerPositionSource pointer,
|
||||
ILocalPlayerModeSource playerMode,
|
||||
ILocalPlayerControllerSource playerController,
|
||||
CameraController camera,
|
||||
ChaseCameraInputState chase,
|
||||
IMovementInputSource movementInput,
|
||||
LocalPlayerOutboundController outbound,
|
||||
ILiveWorldSessionSource session,
|
||||
IMouseLookCursor cursor,
|
||||
IInputMonotonicClock clock)
|
||||
{
|
||||
_mouseSource = mouseSource ?? throw new ArgumentNullException(nameof(mouseSource));
|
||||
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
_playerController = playerController
|
||||
?? throw new ArgumentNullException(nameof(playerController));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_movementInput = movementInput
|
||||
?? throw new ArgumentNullException(nameof(movementInput));
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_state = new MouseLookState(ApplyHorizontalAdjustment);
|
||||
}
|
||||
|
||||
public bool Active => _state.Active;
|
||||
|
||||
public bool HandlePointerAction(InputAction action, ActivationType activation)
|
||||
{
|
||||
if (action == InputAction.AcdreamRmbOrbitHold)
|
||||
{
|
||||
if (activation == ActivationType.Press)
|
||||
_chase.RmbOrbitHeld = _playerMode.IsPlayerMode && _camera.IsChaseMode;
|
||||
else if (activation == ActivationType.Release)
|
||||
_chase.RmbOrbitHeld = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action != InputAction.CameraInstantMouseLook)
|
||||
return false;
|
||||
|
||||
if (activation == ActivationType.Press)
|
||||
Begin();
|
||||
else if (activation == ActivationType.Release)
|
||||
EndAndRestoreCursor();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void QueueRawDelta(float dx, float dy) => _state.QueueDelta(dx, dy);
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
bool wantCaptureMouse = _mouseSource.WantCaptureMouse;
|
||||
if (wantCaptureMouse != _lastWantCaptureMouse)
|
||||
{
|
||||
if (wantCaptureMouse && _state.Active)
|
||||
EndAndRestoreCursor();
|
||||
_state.OnWantCaptureMouseChanged(wantCaptureMouse);
|
||||
_lastWantCaptureMouse = wantCaptureMouse;
|
||||
}
|
||||
|
||||
float nowSeconds = _clock.NowSeconds;
|
||||
if (!_state.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
|
||||
return;
|
||||
|
||||
PlayerMovementController? controller = _playerController.Controller;
|
||||
if (rawX == 0f && rawY == 0f)
|
||||
controller?.StopMouseDrift(_movementInput.Capture());
|
||||
|
||||
(float filteredX, float filteredY) =
|
||||
CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is { } retail
|
||||
? retail.FilterMouseDelta(rawX, rawY, weight: 0.5f, nowSec: nowSeconds)
|
||||
: (rawX, rawY);
|
||||
_state.ApplyDelta(filteredX, _chase.Sensitivity);
|
||||
if (_chase.Retail is { } retailCamera)
|
||||
retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
|
||||
else
|
||||
_chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
|
||||
}
|
||||
|
||||
public void EndAndRestoreCursor()
|
||||
{
|
||||
bool stateWasActive = _state.Active;
|
||||
_state.Release();
|
||||
|
||||
PlayerMovementController? controller = _playerController.Controller;
|
||||
if (controller is not null && controller.EndMouseLook(_movementInput.Capture()))
|
||||
{
|
||||
_outbound.TrySendMovement(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
}
|
||||
|
||||
if (stateWasActive || _cursor.HasSavedMode)
|
||||
_cursor.Restore();
|
||||
}
|
||||
|
||||
public void EndForLifecycle()
|
||||
{
|
||||
EndAndRestoreCursor();
|
||||
_chase.RmbOrbitHeld = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session teardown releases presentation capture without emitting a
|
||||
/// movement packet into the ending session, matching the prior reset edge.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
bool stateWasActive = _state.Active;
|
||||
_state.Release();
|
||||
_chase.RmbOrbitHeld = false;
|
||||
_lastWantCaptureMouse = false;
|
||||
if (stateWasActive || _cursor.HasSavedMode)
|
||||
_cursor.Restore();
|
||||
}
|
||||
|
||||
private void Begin()
|
||||
{
|
||||
PlayerMovementController? controller = _playerController.Controller;
|
||||
if (!_playerMode.IsPlayerMode
|
||||
|| !_camera.IsChaseMode
|
||||
|| controller is not { State: PlayerState.InWorld })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float nowSeconds = _clock.NowSeconds;
|
||||
_state.Press(
|
||||
_pointer.X,
|
||||
_pointer.Y,
|
||||
_mouseSource.WantCaptureMouse,
|
||||
nowSeconds);
|
||||
if (!_state.Active)
|
||||
return;
|
||||
|
||||
if (!controller.BeginMouseLook(_movementInput.Capture()))
|
||||
{
|
||||
_state.Release();
|
||||
return;
|
||||
}
|
||||
|
||||
_outbound.TrySendMovement(
|
||||
_session.CurrentSession,
|
||||
controller,
|
||||
controller.CaptureMovementResult(mouseLookEvent: false));
|
||||
_cursor.Hide();
|
||||
}
|
||||
|
||||
private void ApplyHorizontalAdjustment(float adjustment) =>
|
||||
_playerController.Controller?.SubmitMouseTurnAdjustment(
|
||||
adjustment,
|
||||
_movementInput.Capture());
|
||||
}
|
||||
161
src/AcDream.App/Input/MovementTruthDiagnosticController.cs
Normal file
161
src/AcDream.App/Input/MovementTruthDiagnosticController.cs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
internal interface IMovementTruthDiagnosticSink
|
||||
{
|
||||
void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte);
|
||||
|
||||
void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition);
|
||||
|
||||
void ResetSession();
|
||||
}
|
||||
|
||||
internal sealed class MovementTruthDiagnosticController
|
||||
: IMovementTruthDiagnosticSink
|
||||
{
|
||||
private readonly bool _enabled;
|
||||
private readonly ILocalPlayerControllerSource _player;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private MovementTruthOutbound? _lastOutbound;
|
||||
|
||||
private readonly record struct MovementTruthOutbound(
|
||||
string Kind,
|
||||
uint Sequence,
|
||||
DateTime TimeUtc,
|
||||
Vector3 LocalWorldPosition,
|
||||
Vector3 WirePosition,
|
||||
uint WireCellId,
|
||||
bool IsOnGround,
|
||||
byte ContactByte,
|
||||
Vector3 Velocity);
|
||||
|
||||
public MovementTruthDiagnosticController(
|
||||
bool enabled,
|
||||
ILocalPlayerControllerSource player,
|
||||
ILocalPlayerIdentitySource identity)
|
||||
{
|
||||
_enabled = enabled;
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
}
|
||||
|
||||
public void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte)
|
||||
{
|
||||
if (!_enabled)
|
||||
return;
|
||||
|
||||
Vector3 velocity = _player.Controller?.BodyVelocity ?? Vector3.Zero;
|
||||
_lastOutbound = new MovementTruthOutbound(
|
||||
kind,
|
||||
sequence,
|
||||
DateTime.UtcNow,
|
||||
result.Position,
|
||||
wirePosition,
|
||||
wireCellId,
|
||||
result.IsOnGround,
|
||||
contactByte,
|
||||
velocity);
|
||||
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
|
||||
}
|
||||
|
||||
public void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition)
|
||||
{
|
||||
if (!_enabled || update.Guid != _identity.ServerGuid)
|
||||
return;
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
PlayerMovementController? controller = _player.Controller;
|
||||
Vector3? localPosition = controller?.Position;
|
||||
uint? localCellId = controller?.CellId;
|
||||
Vector3? deltaLocal = localPosition.HasValue
|
||||
? serverWorldPosition - localPosition.Value
|
||||
: null;
|
||||
|
||||
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
|
||||
string localCellText = localCellId.HasValue
|
||||
? FormattableString.Invariant($"0x{localCellId.Value:X8}")
|
||||
: "-";
|
||||
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
|
||||
string deltaLocalLength = deltaLocal.HasValue
|
||||
? FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
|
||||
: "-";
|
||||
|
||||
string lastText = "-";
|
||||
if (_lastOutbound is { } last)
|
||||
{
|
||||
Vector3 deltaOut = serverWorldPosition - last.LocalWorldPosition;
|
||||
double ageMs = (now - last.TimeUtc).TotalMilliseconds;
|
||||
lastText = FormattableString.Invariant(
|
||||
$"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
|
||||
}
|
||||
|
||||
string state = controller?.State.ToString() ?? "-";
|
||||
string velocityText = update.Velocity.HasValue
|
||||
? Fmt(update.Velocity.Value)
|
||||
: "-";
|
||||
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLength} serverVel={velocityText} state={state} lastOut={lastText}"));
|
||||
}
|
||||
|
||||
public void ResetSession() => _lastOutbound = null;
|
||||
|
||||
private static string Fmt(Vector3 value) =>
|
||||
FormattableString.Invariant(
|
||||
$"({value.X:F3},{value.Y:F3},{value.Z:F3})");
|
||||
|
||||
private static string FmtCmd(uint? command) =>
|
||||
command.HasValue
|
||||
? FormattableString.Invariant($"0x{command.Value:X8}")
|
||||
: "-";
|
||||
}
|
||||
|
||||
internal sealed class DelegateMovementTruthDiagnosticSink
|
||||
: IMovementTruthDiagnosticSink
|
||||
{
|
||||
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
|
||||
_outbound;
|
||||
|
||||
public DelegateMovementTruthDiagnosticSink(
|
||||
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
|
||||
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
|
||||
|
||||
public void OnOutbound(
|
||||
string kind,
|
||||
uint sequence,
|
||||
MovementResult result,
|
||||
Vector3 wirePosition,
|
||||
uint wireCellId,
|
||||
byte contactByte) =>
|
||||
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
|
||||
|
||||
public void OnServerEcho(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
Vector3 serverWorldPosition)
|
||||
{
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ namespace AcDream.App.Input;
|
|||
public sealed class OutboundInteractionQueue
|
||||
{
|
||||
private readonly Queue<Action> _pending = new();
|
||||
private uint _clearEpoch;
|
||||
|
||||
public int Count => _pending.Count;
|
||||
|
||||
|
|
@ -33,9 +34,14 @@ public sealed class OutboundInteractionQueue
|
|||
public void Drain()
|
||||
{
|
||||
int count = _pending.Count;
|
||||
while (count-- > 0)
|
||||
uint epoch = _clearEpoch;
|
||||
while (count-- > 0 && epoch == _clearEpoch && _pending.Count > 0)
|
||||
_pending.Dequeue().Invoke();
|
||||
}
|
||||
|
||||
public void Clear() => _pending.Clear();
|
||||
public void Clear()
|
||||
{
|
||||
_pending.Clear();
|
||||
_clearEpoch++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
using System;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Phase K.2 — one-shot guard that auto-enters player mode after a
|
||||
/// successful login once every prerequisite is satisfied. Owned by
|
||||
/// <c>GameWindow</c> and ticked each frame from <c>OnUpdate</c>.
|
||||
/// successful login once every prerequisite is satisfied. The update-frame
|
||||
/// orchestrator ticks it through a typed production context.
|
||||
///
|
||||
/// <para>
|
||||
/// Why is this its own class? The auto-entry has four independent
|
||||
|
|
@ -33,19 +36,108 @@ namespace AcDream.App.Input;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// All preconditions are passed in as predicates so the class doesn't
|
||||
/// pull in <c>WorldSession</c>, <c>PlayerMovementController</c>, or
|
||||
/// any GameWindow-internal types — the unit test wires them to plain
|
||||
/// boolean fields.
|
||||
/// Production preconditions come from focused runtime owners rather than the
|
||||
/// window host. The public delegate constructor remains a narrow test seam for
|
||||
/// plain boolean fixtures.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal interface IPlayerModeAutoEntryContext
|
||||
{
|
||||
bool IsLiveInWorld { get; }
|
||||
bool IsPlayerEntityPresent { get; }
|
||||
bool IsPlayerControllerReady { get; }
|
||||
bool IsWorldReady { get; }
|
||||
bool IsPlayerModeActive { get; }
|
||||
void EnterPlayerMode();
|
||||
}
|
||||
|
||||
/// <summary>Production auto-entry context over canonical runtime owners.</summary>
|
||||
internal sealed class LivePlayerModeAutoEntryContext
|
||||
: IPlayerModeAutoEntryContext
|
||||
{
|
||||
private readonly ILiveInWorldSource _session;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly WorldRevealCoordinator _worldReveal;
|
||||
private readonly ILocalPlayerModeSource _mode;
|
||||
private readonly PlayerModeController _playerMode;
|
||||
|
||||
public LivePlayerModeAutoEntryContext(
|
||||
ILiveInWorldSource session,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
WorldRevealCoordinator worldReveal,
|
||||
ILocalPlayerModeSource mode,
|
||||
PlayerModeController playerMode)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_worldReveal = worldReveal ?? throw new ArgumentNullException(nameof(worldReveal));
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
|
||||
}
|
||||
|
||||
public bool IsLiveInWorld => _session.IsInWorld;
|
||||
|
||||
public bool IsPlayerEntityPresent =>
|
||||
_liveEntities.MaterializedWorldEntities.ContainsKey(_identity.ServerGuid);
|
||||
|
||||
public bool IsPlayerControllerReady => true;
|
||||
|
||||
public bool IsWorldReady =>
|
||||
_liveEntities.TryGetSnapshot(
|
||||
_identity.ServerGuid,
|
||||
out AcDream.Core.Net.WorldSession.EntitySpawn player)
|
||||
&& player.Position is { LandblockId: not 0u } position
|
||||
&& _worldReveal.Evaluate(position.LandblockId).IsReady;
|
||||
|
||||
public bool IsPlayerModeActive => _mode.IsPlayerMode;
|
||||
|
||||
public void EnterPlayerMode() => _playerMode.EnterFromAutoEntry();
|
||||
}
|
||||
|
||||
public sealed class PlayerModeAutoEntry
|
||||
{
|
||||
private readonly Func<bool> _isLiveInWorld;
|
||||
private readonly Func<bool> _isPlayerEntityPresent;
|
||||
private readonly Func<bool> _isPlayerControllerReady;
|
||||
private readonly Func<bool> _isWorldReady;
|
||||
private readonly Action _enterPlayerMode;
|
||||
private sealed class DelegateContext : IPlayerModeAutoEntryContext
|
||||
{
|
||||
private readonly Func<bool> _isLiveInWorld;
|
||||
private readonly Func<bool> _isPlayerEntityPresent;
|
||||
private readonly Func<bool> _isPlayerControllerReady;
|
||||
private readonly Func<bool> _isWorldReady;
|
||||
private readonly Action _enterPlayerMode;
|
||||
private readonly Func<bool> _isPlayerModeActive;
|
||||
|
||||
public DelegateContext(
|
||||
Func<bool> isLiveInWorld,
|
||||
Func<bool> isPlayerEntityPresent,
|
||||
Func<bool> isPlayerControllerReady,
|
||||
Func<bool> isWorldReady,
|
||||
Action enterPlayerMode,
|
||||
Func<bool>? isPlayerModeActive)
|
||||
{
|
||||
_isLiveInWorld = isLiveInWorld
|
||||
?? throw new ArgumentNullException(nameof(isLiveInWorld));
|
||||
_isPlayerEntityPresent = isPlayerEntityPresent
|
||||
?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
|
||||
_isPlayerControllerReady = isPlayerControllerReady
|
||||
?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
|
||||
_isWorldReady = isWorldReady
|
||||
?? throw new ArgumentNullException(nameof(isWorldReady));
|
||||
_enterPlayerMode = enterPlayerMode
|
||||
?? throw new ArgumentNullException(nameof(enterPlayerMode));
|
||||
_isPlayerModeActive = isPlayerModeActive ?? (() => false);
|
||||
}
|
||||
|
||||
public bool IsLiveInWorld => _isLiveInWorld();
|
||||
public bool IsPlayerEntityPresent => _isPlayerEntityPresent();
|
||||
public bool IsPlayerControllerReady => _isPlayerControllerReady();
|
||||
public bool IsWorldReady => _isWorldReady();
|
||||
public bool IsPlayerModeActive => _isPlayerModeActive();
|
||||
public void EnterPlayerMode() => _enterPlayerMode();
|
||||
}
|
||||
|
||||
private readonly IPlayerModeAutoEntryContext _context;
|
||||
|
||||
private bool _armed;
|
||||
|
||||
|
|
@ -71,18 +163,24 @@ public sealed class PlayerModeAutoEntry
|
|||
/// player transition). Must construct the controller + chase
|
||||
/// camera and switch the active camera; the auto-entry doesn't
|
||||
/// reach inside.</param>
|
||||
internal PlayerModeAutoEntry(IPlayerModeAutoEntryContext context) =>
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
|
||||
public PlayerModeAutoEntry(
|
||||
Func<bool> isLiveInWorld,
|
||||
Func<bool> isPlayerEntityPresent,
|
||||
Func<bool> isPlayerControllerReady,
|
||||
Func<bool> isWorldReady,
|
||||
Action enterPlayerMode)
|
||||
Action enterPlayerMode,
|
||||
Func<bool>? isPlayerModeActive = null)
|
||||
: this(new DelegateContext(
|
||||
isLiveInWorld,
|
||||
isPlayerEntityPresent,
|
||||
isPlayerControllerReady,
|
||||
isWorldReady,
|
||||
enterPlayerMode,
|
||||
isPlayerModeActive))
|
||||
{
|
||||
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
|
||||
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
|
||||
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
|
||||
_isWorldReady = isWorldReady ?? throw new ArgumentNullException(nameof(isWorldReady));
|
||||
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
|
||||
}
|
||||
|
||||
/// <summary>True iff <see cref="TryEnter"/> would still fire if the
|
||||
|
|
@ -112,13 +210,18 @@ public sealed class PlayerModeAutoEntry
|
|||
public bool TryEnter()
|
||||
{
|
||||
if (!_armed) return false;
|
||||
if (!_isLiveInWorld()) return false;
|
||||
if (!_isPlayerEntityPresent()) return false;
|
||||
if (!_isPlayerControllerReady()) return false;
|
||||
if (!_isWorldReady()) return false;
|
||||
if (_context.IsPlayerModeActive)
|
||||
{
|
||||
_armed = false;
|
||||
return false;
|
||||
}
|
||||
if (!_context.IsLiveInWorld) return false;
|
||||
if (!_context.IsPlayerEntityPresent) return false;
|
||||
if (!_context.IsPlayerControllerReady) return false;
|
||||
if (!_context.IsWorldReady) return false;
|
||||
|
||||
_armed = false;
|
||||
_enterPlayerMode();
|
||||
_context.EnterPlayerMode();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
572
src/AcDream.App/Input/PlayerModeController.cs
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Sole writer for local player-mode, movement-controller, physics-host, and
|
||||
/// chase-camera lifetimes. The window host wires this owner but never mutates
|
||||
/// those slots directly.
|
||||
/// </summary>
|
||||
internal sealed class PlayerModeController : ILocalPlayerTeleportModeOperations
|
||||
{
|
||||
private readonly LocalPlayerModeState _mode;
|
||||
private readonly LocalPlayerControllerSlot _controllerSlot;
|
||||
private readonly LocalPlayerPhysicsHostSlot _hostSlot;
|
||||
private readonly ChaseCameraInputState _chase;
|
||||
private readonly CameraController _camera;
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly object _datLock;
|
||||
private readonly PhysicsDataCache _physicsDataCache;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
|
||||
private readonly LocalPlayerAnimationController _animation;
|
||||
private readonly LocalPlayerShadowSynchronizer _shadow;
|
||||
private readonly IPlayerApproachCompletionLifetimeOwner _approachCompletions;
|
||||
private readonly ILocalPlayerTeleportInputLifetime _input;
|
||||
private readonly ILiveInWorldSource _session;
|
||||
private readonly MovementTruthDiagnosticController _movementDiagnostics;
|
||||
private readonly LocalPlayerSkillState _skills;
|
||||
private readonly IViewportAspectSource _viewport;
|
||||
private PlayerModeAutoEntry? _autoEntry;
|
||||
private IPlayerApproachCompletionSink? _approachLifetime;
|
||||
|
||||
public PlayerModeController(
|
||||
LocalPlayerModeState mode,
|
||||
LocalPlayerControllerSlot controllerSlot,
|
||||
LocalPlayerPhysicsHostSlot hostSlot,
|
||||
ChaseCameraInputState chase,
|
||||
CameraController camera,
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveWorldOriginState origin,
|
||||
ILiveEntityMotionRuntimeBindings motionBindings,
|
||||
IDatReaderWriter dats,
|
||||
object datLock,
|
||||
PhysicsDataCache physicsDataCache,
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
|
||||
LocalPlayerAnimationController animation,
|
||||
LocalPlayerShadowSynchronizer shadow,
|
||||
IPlayerApproachCompletionLifetimeOwner approachCompletions,
|
||||
ILocalPlayerTeleportInputLifetime input,
|
||||
ILiveInWorldSource session,
|
||||
MovementTruthDiagnosticController movementDiagnostics,
|
||||
LocalPlayerSkillState skills,
|
||||
IViewportAspectSource viewport)
|
||||
{
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_controllerSlot = controllerSlot ?? throw new ArgumentNullException(nameof(controllerSlot));
|
||||
_hostSlot = hostSlot ?? throw new ArgumentNullException(nameof(hostSlot));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||||
_animations = animations ?? throw new ArgumentNullException(nameof(animations));
|
||||
_animation = animation ?? throw new ArgumentNullException(nameof(animation));
|
||||
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
|
||||
_approachCompletions = approachCompletions
|
||||
?? throw new ArgumentNullException(nameof(approachCompletions));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_movementDiagnostics = movementDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(movementDiagnostics));
|
||||
_skills = skills ?? throw new ArgumentNullException(nameof(skills));
|
||||
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
|
||||
}
|
||||
|
||||
public PlayerMovementController? Controller => _controllerSlot.Controller;
|
||||
public Matrix4x4 Projection => _camera.Active.Projection;
|
||||
|
||||
public void BindAutoEntry(PlayerModeAutoEntry autoEntry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(autoEntry);
|
||||
if (_autoEntry is not null)
|
||||
throw new InvalidOperationException("Player-mode auto-entry is already bound.");
|
||||
_autoEntry = autoEntry;
|
||||
}
|
||||
|
||||
public void Toggle()
|
||||
{
|
||||
if (!_session.IsInWorld)
|
||||
return;
|
||||
|
||||
_autoEntry?.Cancel();
|
||||
if (_mode.IsPlayerMode)
|
||||
Exit();
|
||||
else
|
||||
_ = TryEnter("Tab");
|
||||
}
|
||||
|
||||
public void EnterFromAutoEntry()
|
||||
{
|
||||
if (TryEnter("auto-entry"))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: auto-entered player mode for 0x{_identity.ServerGuid:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryEnterPortalSpace()
|
||||
{
|
||||
// Portal activation supersedes the login-only guard. Otherwise the
|
||||
// later auto-entry tick can rebuild this just-created controller and
|
||||
// replace PortalSpace with the default InWorld state.
|
||||
_autoEntry?.Cancel();
|
||||
if (Controller is null && !TryEnter("teleport"))
|
||||
return false;
|
||||
|
||||
if (Controller is not { } controller)
|
||||
return false;
|
||||
|
||||
controller.State = PlayerState.PortalSpace;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnterWorld()
|
||||
{
|
||||
if (Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
}
|
||||
|
||||
public void Exit()
|
||||
{
|
||||
var failures = new List<Exception>();
|
||||
try { _input.EndMouseLook(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _camera.ExitChaseMode(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { RetireApproachLifetime(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_mode.IsPlayerMode = false;
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException("Player-mode exit was incomplete.", failures);
|
||||
}
|
||||
|
||||
public void ToggleFlyOrChase()
|
||||
{
|
||||
_autoEntry?.Cancel();
|
||||
if (_camera.IsFlyMode
|
||||
&& _mode.IsPlayerMode
|
||||
&& _chase.Legacy is { } legacy)
|
||||
{
|
||||
_chase.Retail ??= new RetailChaseCamera
|
||||
{
|
||||
Aspect = legacy.Aspect,
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
|
||||
};
|
||||
_camera.EnterChaseMode(legacy, _chase.Retail);
|
||||
return;
|
||||
}
|
||||
|
||||
_camera.ToggleFly();
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
_autoEntry?.Cancel();
|
||||
var failures = new List<Exception>();
|
||||
try { _camera.ExitChaseMode(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { RetireApproachLifetime(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_mode.ResetSession();
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
try { _movementDiagnostics.ResetSession(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _shadow.ResetSession(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException("Player-mode session reset was incomplete.", failures);
|
||||
}
|
||||
|
||||
private bool TryEnter(string loggingTag)
|
||||
{
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
if (!_liveEntities.MaterializedWorldEntities.TryGetValue(
|
||||
playerGuid,
|
||||
out WorldEntity? playerEntity))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — player entity 0x{playerGuid:X8} not found yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_liveEntities.TryGetRecord(playerGuid, out LiveEntityRecord playerRecord))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — player record 0x{playerGuid:X8} not found yet");
|
||||
return false;
|
||||
}
|
||||
|
||||
BuildControllerAndCamera(
|
||||
loggingTag,
|
||||
playerGuid,
|
||||
playerEntity,
|
||||
playerRecord);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void BuildControllerAndCamera(
|
||||
string loggingTag,
|
||||
uint playerGuid,
|
||||
WorldEntity playerEntity,
|
||||
LiveEntityRecord playerRecord)
|
||||
{
|
||||
IPlayerApproachCompletionSink approachLifetime =
|
||||
_approachCompletions.BeginControllerLifetime();
|
||||
bool lifetimeCommitted = false;
|
||||
bool cameraAttempted = false;
|
||||
bool shadowAttempted = false;
|
||||
CameraController.CameraState priorCamera = _camera.CaptureState();
|
||||
LocalPlayerShadowState.Snapshot? priorShadow = _shadow.Capture();
|
||||
try
|
||||
{
|
||||
var controller = new PlayerMovementController(
|
||||
_physics,
|
||||
playerRecord.ObjectClock);
|
||||
controller.ApplyPhysicsState(playerRecord.FinalPhysicsState);
|
||||
|
||||
// Retail MovementManager::MakeMoveToManager @ 0x00524000 creates one
|
||||
// MoveToManager facade over the local CPhysicsObj seams.
|
||||
PlayerMovementController capturedController = controller;
|
||||
EntityPhysicsHost playerHost = null!;
|
||||
controller.Movement.MoveToFactory = () =>
|
||||
{
|
||||
var moveTo = new MoveToManager(
|
||||
capturedController.Motion,
|
||||
stopCompletely: () =>
|
||||
capturedController.StopCompletelyAtPhysicsObjectBoundary(),
|
||||
getPosition: () => new Position(
|
||||
capturedController.CellId,
|
||||
capturedController.Position,
|
||||
capturedController.BodyOrientation),
|
||||
getHeading: () => MoveToMath.HeadingFromYaw(capturedController.Yaw),
|
||||
setHeading: (heading, _) => capturedController.Yaw =
|
||||
MoveToMath.YawFromHeading(heading),
|
||||
getOwnRadius: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Radius,
|
||||
getOwnHeight: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Height,
|
||||
contact: () => capturedController.BodyInContact,
|
||||
isInterpolating: () => false,
|
||||
getVelocity: () => capturedController.BodyVelocity,
|
||||
getSelfId: () => playerGuid,
|
||||
setTarget: (context, target, radius, quantum) =>
|
||||
playerHost.SetTarget(context, target, radius, quantum),
|
||||
clearTarget: playerHost.ClearTarget,
|
||||
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
|
||||
setTargetQuantum: playerHost.TargetManager.SetTargetQuantum,
|
||||
curTime: () => capturedController.SimTimeSeconds);
|
||||
|
||||
moveTo.MoveToComplete = error =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
Console.WriteLine($"[autowalk-end] reason=complete err={error}");
|
||||
if (error == WeenieError.None)
|
||||
approachLifetime.PublishNaturalCompletion();
|
||||
else
|
||||
approachLifetime.PublishCancellation(error);
|
||||
};
|
||||
moveTo.MoveToCancelled = error =>
|
||||
approachLifetime.PublishCancellation(error);
|
||||
moveTo.StickTo = (target, radius, height) =>
|
||||
playerHost.PositionManager.StickTo(target, radius, height);
|
||||
moveTo.Unstick = () => playerHost.PositionManager.UnStick();
|
||||
return moveTo;
|
||||
};
|
||||
|
||||
MovementManager exactMovement = controller.Movement;
|
||||
var configuredHost = new EntityPhysicsHost(
|
||||
playerGuid,
|
||||
getPosition: () => new Position(
|
||||
playerRecord.FullCellId,
|
||||
playerRecord.WorldEntity?.Position ?? capturedController.Position,
|
||||
capturedController.BodyOrientation),
|
||||
getVelocity: () => capturedController.BodyVelocity,
|
||||
getRadius: () => _motionBindings.GetSetupCylinder(
|
||||
playerGuid,
|
||||
playerEntity).Radius,
|
||||
inContact: () => capturedController.BodyInContact,
|
||||
minterpMaxSpeed: () => capturedController.Motion.GetMaxSpeed(),
|
||||
curTime: () => capturedController.SimTimeSeconds,
|
||||
physicsTimerTime: () => capturedController.SimTimeSeconds,
|
||||
getObjectA: _motionBindings.ResolvePhysicsHost,
|
||||
handleUpdateTarget: info =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[autowalk-target] object=0x{info.ObjectId:X8} "
|
||||
+ $"status={info.Status} context={info.ContextId} "
|
||||
+ $"target=({info.TargetPosition.Frame.Origin.X:F2},"
|
||||
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
|
||||
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
|
||||
}
|
||||
exactMovement.HandleUpdateTarget(info);
|
||||
},
|
||||
interruptCurrentMovement: () => exactMovement.CancelMoveTo(
|
||||
WeenieError.ActionCancelled));
|
||||
playerHost = EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
|
||||
exactMovement.MakeMoveToManager();
|
||||
controller.Motion.UnstickFromObject = () =>
|
||||
playerHost.PositionManager.UnStick();
|
||||
controller.PositionManager = playerHost.PositionManager;
|
||||
controller.Motion.InterruptCurrentMovement = () =>
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeAutoWalkEnabled
|
||||
&& exactMovement.IsMovingTo())
|
||||
{
|
||||
Console.WriteLine("[autowalk-end] reason=interrupt");
|
||||
}
|
||||
exactMovement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
};
|
||||
|
||||
if (_skills.ApplyTo(controller))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: {loggingTag} — applied server skills "
|
||||
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
|
||||
}
|
||||
|
||||
ApplyStepHeights(controller, playerEntity);
|
||||
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
|
||||
|
||||
if (_animations.TryGetValue(playerEntity.Id, out LiveEntityAnimationState? animation)
|
||||
&& animation.Sequencer is { } sequencer)
|
||||
{
|
||||
controller.AttachCycleVelocityAccessor(() => sequencer.CurrentVelocity);
|
||||
controller.ObjectScale = animation.Scale;
|
||||
controller.AttachAnimationRootMotionSource(
|
||||
_animation.AdvanceRoot,
|
||||
_animation.CaptureHooks);
|
||||
controller.Motion.RemoveLinkAnimations =
|
||||
sequencer.Manager.HandleEnterWorld;
|
||||
controller.Motion.InitializeMotionTables =
|
||||
sequencer.Manager.InitializeState;
|
||||
controller.Motion.CheckForCompletedMotions =
|
||||
sequencer.Manager.CheckForCompletedMotions;
|
||||
controller.Motion.DefaultSink =
|
||||
new MotionTableDispatchSink(sequencer);
|
||||
}
|
||||
|
||||
ResolveResult initial = _physics.Resolve(
|
||||
playerEntity.Position,
|
||||
initialCellId,
|
||||
Vector3.Zero,
|
||||
100f);
|
||||
var (placementRadius, placementHeight) =
|
||||
_motionBindings.GetSetupCylinder(playerGuid, playerEntity);
|
||||
if (placementRadius < 0.05f)
|
||||
{
|
||||
placementRadius = 0.48f;
|
||||
placementHeight = 1.835f;
|
||||
}
|
||||
|
||||
ResolveResult placement = _physics.ResolvePlacement(
|
||||
initial.Position,
|
||||
initial.CellId,
|
||||
placementRadius,
|
||||
placementHeight,
|
||||
controller.StepUpHeight,
|
||||
controller.StepDownHeight,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
playerEntity.Id);
|
||||
if (placement.Ok)
|
||||
initial = placement;
|
||||
|
||||
controller.PreparePositionForCommit(
|
||||
initial.Position,
|
||||
initial.CellId,
|
||||
CellLocalForSeed(initial.Position, initial.CellId));
|
||||
controller.SetBodyOrientation(playerEntity.Rotation);
|
||||
|
||||
var legacyCamera = new ChaseCamera { Aspect = _viewport.Aspect };
|
||||
var retailCamera = new RetailChaseCamera
|
||||
{
|
||||
Aspect = _viewport.Aspect,
|
||||
CollisionProbe = new PhysicsCameraCollisionProbe(_physics),
|
||||
};
|
||||
cameraAttempted = true;
|
||||
_camera.EnterChaseMode(legacyCamera, retailCamera);
|
||||
|
||||
EntityPhysicsHost stableAfterCamera =
|
||||
EntityPhysicsHost.SelectStableHostWithoutRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
if (!ReferenceEquals(stableAfterCamera, playerHost))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local physics host changed during chase-camera activation.");
|
||||
}
|
||||
|
||||
shadowAttempted = true;
|
||||
_shadow.SyncPose(
|
||||
playerEntity,
|
||||
initial.Position,
|
||||
playerEntity.Rotation,
|
||||
initial.CellId,
|
||||
force: true);
|
||||
|
||||
// Publish the incarnation-stable CPhysicsObj delegates only after all
|
||||
// DAT, placement, shadow, and camera preparation has succeeded. A
|
||||
// late preparation failure therefore cannot expose an abandoned
|
||||
// controller through LiveEntityRecord.PhysicsHost.
|
||||
EntityPhysicsHost publishedHost = EntityPhysicsHost.InstallOrRebind(
|
||||
_liveEntities,
|
||||
playerRecord,
|
||||
configuredHost);
|
||||
if (!ReferenceEquals(publishedHost, playerHost))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The local physics host changed between preparation and commit.");
|
||||
}
|
||||
|
||||
playerEntity.SetPosition(initial.Position);
|
||||
playerEntity.ParentCellId = initial.CellId;
|
||||
controller.CommitPreparedPosition();
|
||||
_hostSlot.Host = publishedHost;
|
||||
_controllerSlot.Controller = controller;
|
||||
_chase.Legacy = legacyCamera;
|
||||
_chase.Retail = retailCamera;
|
||||
_mode.IsPlayerMode = true;
|
||||
_mode.ChaseModeEverEntered = true;
|
||||
_approachLifetime = approachLifetime;
|
||||
lifetimeCommitted = true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var failures = new List<Exception> { error };
|
||||
if (shadowAttempted)
|
||||
{
|
||||
try { _shadow.Restore(playerEntity, priorShadow); }
|
||||
catch (Exception cleanupError) { failures.Add(cleanupError); }
|
||||
}
|
||||
if (cameraAttempted)
|
||||
{
|
||||
try { _camera.RestoreState(priorCamera); }
|
||||
catch (Exception cleanupError) { failures.Add(cleanupError); }
|
||||
}
|
||||
|
||||
_mode.IsPlayerMode = false;
|
||||
_controllerSlot.Controller = null;
|
||||
_hostSlot.Host = null;
|
||||
_chase.Legacy = null;
|
||||
_chase.Retail = null;
|
||||
|
||||
if (failures.Count != 1)
|
||||
throw new AggregateException(
|
||||
"Player-mode entry failed and rollback was incomplete.",
|
||||
failures);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!lifetimeCommitted)
|
||||
_approachCompletions.RetireControllerLifetime(approachLifetime);
|
||||
}
|
||||
}
|
||||
|
||||
private void RetireApproachLifetime()
|
||||
{
|
||||
if (_approachLifetime is not { } lifetime)
|
||||
return;
|
||||
_approachLifetime = null;
|
||||
_approachCompletions.RetireControllerLifetime(lifetime);
|
||||
}
|
||||
|
||||
private void ApplyStepHeights(
|
||||
PlayerMovementController controller,
|
||||
WorldEntity playerEntity)
|
||||
{
|
||||
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
DatReaderWriter.DBObjs.Setup? setup;
|
||||
lock (_datLock)
|
||||
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
|
||||
playerEntity.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup);
|
||||
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
|
||||
? setup.StepUpHeight
|
||||
: 0.4f;
|
||||
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
|
||||
? setup.StepDownHeight
|
||||
: 0.4f;
|
||||
Console.WriteLine(
|
||||
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
|
||||
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
|
||||
+ $"StepDown={controller.StepDownHeight:F3} m "
|
||||
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})");
|
||||
return;
|
||||
}
|
||||
|
||||
controller.StepUpHeight = 0.4f;
|
||||
controller.StepDownHeight = 0.4f;
|
||||
Console.WriteLine(
|
||||
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
|
||||
}
|
||||
|
||||
private uint ResolveInitialCell(uint playerGuid, WorldEntity playerEntity)
|
||||
{
|
||||
if (_liveEntities.Snapshots.TryGetValue(playerGuid, out var spawn)
|
||||
&& spawn.Position is { LandblockId: not 0u } position)
|
||||
{
|
||||
return position.LandblockId;
|
||||
}
|
||||
|
||||
int landblockX = _origin.CenterX
|
||||
+ (int)MathF.Floor(playerEntity.Position.X / 192f);
|
||||
int landblockY = _origin.CenterY
|
||||
+ (int)MathF.Floor(playerEntity.Position.Y / 192f);
|
||||
return ((uint)landblockX << 24)
|
||||
| ((uint)landblockY << 16)
|
||||
| 0x0001u;
|
||||
}
|
||||
|
||||
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _origin.CenterX) * 192f,
|
||||
(landblockY - _origin.CenterY) * 192f,
|
||||
0f);
|
||||
return worldPosition - origin;
|
||||
}
|
||||
}
|
||||
|
|
@ -1092,7 +1092,10 @@ public sealed class PlayerMovementController
|
|||
// route through here. When PhysicsDiagnostics.ProbeCellEnabled is
|
||||
// off this collapses to a single bool-compare + assignment — zero
|
||||
// logging cost.
|
||||
private void UpdateCellId(uint newCellId, string reason)
|
||||
private void UpdateCellId(
|
||||
uint newCellId,
|
||||
string reason,
|
||||
bool publishRenderRoot = true)
|
||||
{
|
||||
if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
|
||||
{
|
||||
|
|
@ -1109,7 +1112,8 @@ public sealed class PlayerMovementController
|
|||
// jump-looping near the cottage doorway clobbered the render root every tick → the render
|
||||
// rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear
|
||||
// color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]).
|
||||
_physics.UpdatePlayerCurrCell(newCellId);
|
||||
if (publishRenderRoot)
|
||||
_physics.UpdatePlayerCurrCell(newCellId);
|
||||
}
|
||||
|
||||
public void SetPosition(Vector3 pos, uint cellId)
|
||||
|
|
@ -1127,11 +1131,47 @@ public sealed class PlayerMovementController
|
|||
/// directly via <c>SnapToCell</c> rather than delta-syncing through the setter.
|
||||
/// </summary>
|
||||
public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
=> SetPositionCore(
|
||||
pos,
|
||||
cellId,
|
||||
cellLocal,
|
||||
publishSharedState: true);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a new local controller's canonical body pose without publishing
|
||||
/// the renderer's global current cell or mutating the incarnation-stable
|
||||
/// PositionManager. Player-mode entry commits those shared edges only
|
||||
/// after camera, shadow, and host preparation succeeds.
|
||||
/// </summary>
|
||||
internal void PreparePositionForCommit(
|
||||
Vector3 pos,
|
||||
uint cellId,
|
||||
Vector3 cellLocal) =>
|
||||
SetPositionCore(
|
||||
pos,
|
||||
cellId,
|
||||
cellLocal,
|
||||
publishSharedState: false);
|
||||
|
||||
internal void CommitPreparedPosition()
|
||||
{
|
||||
_physics.UpdatePlayerCurrCell(CellId);
|
||||
PositionManager?.UnStick();
|
||||
}
|
||||
|
||||
private void SetPositionCore(
|
||||
Vector3 pos,
|
||||
uint cellId,
|
||||
Vector3 cellLocal,
|
||||
bool publishSharedState)
|
||||
{
|
||||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
UpdateCellId(
|
||||
_body.CellPosition.ObjCellId,
|
||||
"teleport",
|
||||
publishSharedState);
|
||||
|
||||
// Treat as grounded after a server-side position snap.
|
||||
_body.TransientState = TransientStateFlags.Contact
|
||||
|
|
@ -1164,7 +1204,8 @@ public sealed class PlayerMovementController
|
|||
// tears down any active stick. (StopInterpolating/UnConstrain have no
|
||||
// armed acdream counterparts — no local-player InterpolationManager,
|
||||
// constraint leash unarmed per #167.)
|
||||
PositionManager?.UnStick();
|
||||
if (publishSharedState)
|
||||
PositionManager?.UnStick();
|
||||
// Reset the edge tracker: the stop wiped the motion state, so keys
|
||||
// still physically held must re-fire as press edges on the next
|
||||
// Update (matches the pre-W6 level-triggered behavior of walking
|
||||
|
|
|
|||
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
486
src/AcDream.App/Input/QuiescentInputContext.cs
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Non-owning input-context view for third-party frontends such as Silk's
|
||||
/// ImGuiController. It interposes named device relays so frontend callbacks can
|
||||
/// be silenced before a long live-session close and physically detached later,
|
||||
/// without disposing the canonical Silk input context.
|
||||
/// </summary>
|
||||
internal sealed class QuiescentInputContext : IInputContext
|
||||
{
|
||||
private readonly IInputContext _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly QuiescentKeyboard[] _keyboards;
|
||||
private readonly QuiescentMouse[] _mice;
|
||||
private readonly Action<IInputDevice, bool> _connectionChanged;
|
||||
private Action<IInputDevice, bool>? _connectionSubscribers;
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _connectionAttached;
|
||||
private int _active;
|
||||
private bool _activationStarted;
|
||||
private int _disposeRequested;
|
||||
|
||||
public QuiescentInputContext(
|
||||
IInputContext inner,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyboards = inner.Keyboards
|
||||
.Select(keyboard => new QuiescentKeyboard(keyboard, quiescence))
|
||||
.ToArray();
|
||||
_mice = inner.Mice
|
||||
.Select(mouse => new QuiescentMouse(mouse, quiescence))
|
||||
.ToArray();
|
||||
_connectionChanged = OnConnectionChanged;
|
||||
}
|
||||
|
||||
public nint Handle => _inner.Handle;
|
||||
public IReadOnlyList<IGamepad> Gamepads => _inner.Gamepads;
|
||||
public IReadOnlyList<IJoystick> Joysticks => _inner.Joysticks;
|
||||
public IReadOnlyList<IKeyboard> Keyboards => _keyboards;
|
||||
public IReadOnlyList<IMouse> Mice => _mice;
|
||||
public IReadOnlyList<IInputDevice> OtherDevices => _inner.OtherDevices;
|
||||
|
||||
public event Action<IInputDevice, bool>? ConnectionChanged
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_connectionSubscribers += value;
|
||||
if (_connectionAttached) return;
|
||||
_connectionAttached = true;
|
||||
_inner.ConnectionChanged += _connectionChanged;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_connectionSubscribers -= value;
|
||||
if (_connectionSubscribers is not null || !_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete =>
|
||||
!_connectionAttached
|
||||
&& _keyboards.All(static keyboard => keyboard.IsDisposalComplete)
|
||||
&& _mice.All(static mouse => mouse.IsDisposalComplete);
|
||||
|
||||
/// <summary>
|
||||
/// Opens delivery only after the third-party frontend constructor has
|
||||
/// finished installing every callback. An event raised reentrantly from an
|
||||
/// add accessor therefore cannot enter a partially constructed frontend.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_activationStarted)
|
||||
throw new InvalidOperationException(
|
||||
"Frontend input activation has already been attempted.");
|
||||
_activationStarted = true;
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Activate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Activate();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
Interlocked.Exchange(ref _active, 0);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.Deactivate();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.Deactivate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
foreach (QuiescentKeyboard keyboard in _keyboards)
|
||||
keyboard.RequestDisposal();
|
||||
foreach (QuiescentMouse mouse in _mice)
|
||||
mouse.RequestDisposal();
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnConnectionChanged(IInputDevice device, bool connected) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
_connectionSubscribers?.Invoke(device, connected);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction()
|
||||
{
|
||||
if (_detach is not null)
|
||||
return _detach;
|
||||
|
||||
var operations = new List<ResourceShutdownOperation>();
|
||||
for (int i = _mice.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend mouse {index}",
|
||||
_mice[index].Dispose));
|
||||
}
|
||||
for (int i = _keyboards.Length - 1; i >= 0; i--)
|
||||
{
|
||||
int index = i;
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
$"frontend keyboard {index}",
|
||||
_keyboards[index].Dispose));
|
||||
}
|
||||
operations.Add(new ResourceShutdownOperation(
|
||||
"frontend connection event",
|
||||
RemoveConnectionChanged));
|
||||
_detach = new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend input callbacks", operations.ToArray()));
|
||||
return _detach;
|
||||
}
|
||||
|
||||
private void RemoveConnectionChanged()
|
||||
{
|
||||
if (!_connectionAttached) return;
|
||||
_inner.ConnectionChanged -= _connectionChanged;
|
||||
_connectionAttached = false;
|
||||
_connectionSubscribers = null;
|
||||
}
|
||||
|
||||
private sealed class QuiescentKeyboard : IKeyboard, IDisposable
|
||||
{
|
||||
private readonly IKeyboard _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IKeyboard, Key, int> _downRelay;
|
||||
private readonly Action<IKeyboard, Key, int> _upRelay;
|
||||
private readonly Action<IKeyboard, char> _charRelay;
|
||||
private Action<IKeyboard, Key, int>? _downSubscribers;
|
||||
private Action<IKeyboard, Key, int>? _upSubscribers;
|
||||
private Action<IKeyboard, char>? _charSubscribers;
|
||||
private readonly bool[] _attached = new bool[3];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentKeyboard(IKeyboard inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_charRelay = OnChar;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<Key> SupportedKeys => _inner.SupportedKeys;
|
||||
public string ClipboardText
|
||||
{
|
||||
get => _inner.ClipboardText;
|
||||
set => _inner.ClipboardText = value;
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyDown
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_downSubscribers += value;
|
||||
if (_attached[0]) return;
|
||||
_attached[0] = true;
|
||||
_inner.KeyDown += _downRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_downSubscribers -= value;
|
||||
if (_downSubscribers is not null || !_attached[0]) return;
|
||||
_inner.KeyDown -= _downRelay;
|
||||
_attached[0] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, Key, int>? KeyUp
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_upSubscribers += value;
|
||||
if (_attached[1]) return;
|
||||
_attached[1] = true;
|
||||
_inner.KeyUp += _upRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_upSubscribers -= value;
|
||||
if (_upSubscribers is not null || !_attached[1]) return;
|
||||
_inner.KeyUp -= _upRelay;
|
||||
_attached[1] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public event Action<IKeyboard, char>? KeyChar
|
||||
{
|
||||
add
|
||||
{
|
||||
if (value is null) return;
|
||||
ThrowIfDisposed();
|
||||
_charSubscribers += value;
|
||||
if (_attached[2]) return;
|
||||
_attached[2] = true;
|
||||
_inner.KeyChar += _charRelay;
|
||||
}
|
||||
remove
|
||||
{
|
||||
if (value is null) return;
|
||||
_charSubscribers -= value;
|
||||
if (_charSubscribers is not null || !_attached[2]) return;
|
||||
_inner.KeyChar -= _charRelay;
|
||||
_attached[2] = false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsKeyPressed(Key key) => _inner.IsKeyPressed(key);
|
||||
public bool IsScancodePressed(int scancode) => _inner.IsScancodePressed(scancode);
|
||||
public void BeginInput() => _inner.BeginInput();
|
||||
public void EndInput() => _inner.EndInput();
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend keyboard callbacks",
|
||||
[
|
||||
new("character", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnUp(IKeyboard _, Key key, int scanCode) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, key, scanCode));
|
||||
private void OnChar(IKeyboard _, char value) =>
|
||||
Invoke(() => _charSubscribers?.Invoke(this, value));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.KeyDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.KeyUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.KeyChar -= _charRelay; _charSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QuiescentMouse : IMouse, IDisposable
|
||||
{
|
||||
private readonly IMouse _inner;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<IMouse, MouseButton> _downRelay;
|
||||
private readonly Action<IMouse, MouseButton> _upRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _clickRelay;
|
||||
private readonly Action<IMouse, MouseButton, Vector2> _doubleClickRelay;
|
||||
private readonly Action<IMouse, Vector2> _moveRelay;
|
||||
private readonly Action<IMouse, ScrollWheel> _scrollRelay;
|
||||
private Action<IMouse, MouseButton>? _downSubscribers;
|
||||
private Action<IMouse, MouseButton>? _upSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _clickSubscribers;
|
||||
private Action<IMouse, MouseButton, Vector2>? _doubleClickSubscribers;
|
||||
private Action<IMouse, Vector2>? _moveSubscribers;
|
||||
private Action<IMouse, ScrollWheel>? _scrollSubscribers;
|
||||
private readonly bool[] _attached = new bool[6];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public QuiescentMouse(IMouse inner, HostQuiescenceGate quiescence)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
_quiescence = quiescence;
|
||||
_downRelay = OnDown;
|
||||
_upRelay = OnUp;
|
||||
_clickRelay = OnClick;
|
||||
_doubleClickRelay = OnDoubleClick;
|
||||
_moveRelay = OnMove;
|
||||
_scrollRelay = OnScroll;
|
||||
}
|
||||
|
||||
public string Name => _inner.Name;
|
||||
public int Index => _inner.Index;
|
||||
public bool IsConnected => _inner.IsConnected;
|
||||
public IReadOnlyList<MouseButton> SupportedButtons => _inner.SupportedButtons;
|
||||
public IReadOnlyList<ScrollWheel> ScrollWheels => _inner.ScrollWheels;
|
||||
public Vector2 Position { get => _inner.Position; set => _inner.Position = value; }
|
||||
public ICursor Cursor => _inner.Cursor;
|
||||
public int DoubleClickTime { get => _inner.DoubleClickTime; set => _inner.DoubleClickTime = value; }
|
||||
public int DoubleClickRange { get => _inner.DoubleClickRange; set => _inner.DoubleClickRange = value; }
|
||||
|
||||
public event Action<IMouse, MouseButton> MouseDown
|
||||
{
|
||||
add => Add(ref _downSubscribers, value, 0, () => _inner.MouseDown += _downRelay);
|
||||
remove => RemoveSubscriber(ref _downSubscribers, value, 0, () => _inner.MouseDown -= _downRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton> MouseUp
|
||||
{
|
||||
add => Add(ref _upSubscribers, value, 1, () => _inner.MouseUp += _upRelay);
|
||||
remove => RemoveSubscriber(ref _upSubscribers, value, 1, () => _inner.MouseUp -= _upRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> Click
|
||||
{
|
||||
add => Add(ref _clickSubscribers, value, 2, () => _inner.Click += _clickRelay);
|
||||
remove => RemoveSubscriber(ref _clickSubscribers, value, 2, () => _inner.Click -= _clickRelay);
|
||||
}
|
||||
public event Action<IMouse, MouseButton, Vector2> DoubleClick
|
||||
{
|
||||
add => Add(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick += _doubleClickRelay);
|
||||
remove => RemoveSubscriber(ref _doubleClickSubscribers, value, 3, () => _inner.DoubleClick -= _doubleClickRelay);
|
||||
}
|
||||
public event Action<IMouse, Vector2> MouseMove
|
||||
{
|
||||
add => Add(ref _moveSubscribers, value, 4, () => _inner.MouseMove += _moveRelay);
|
||||
remove => RemoveSubscriber(ref _moveSubscribers, value, 4, () => _inner.MouseMove -= _moveRelay);
|
||||
}
|
||||
public event Action<IMouse, ScrollWheel> Scroll
|
||||
{
|
||||
add => Add(ref _scrollSubscribers, value, 5, () => _inner.Scroll += _scrollRelay);
|
||||
remove => RemoveSubscriber(ref _scrollSubscribers, value, 5, () => _inner.Scroll -= _scrollRelay);
|
||||
}
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
public bool IsButtonPressed(MouseButton button) => _inner.IsButtonPressed(button);
|
||||
public void Activate()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void RequestDisposal() =>
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RequestDisposal();
|
||||
Deactivate();
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("frontend mouse callbacks",
|
||||
[
|
||||
new("scroll", () => RemovePhysical(5)),
|
||||
new("move", () => RemovePhysical(4)),
|
||||
new("double click", () => RemovePhysical(3)),
|
||||
new("click", () => RemovePhysical(2)),
|
||||
new("up", () => RemovePhysical(1)),
|
||||
new("down", () => RemovePhysical(0)),
|
||||
]));
|
||||
_detach.CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() =>
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _downSubscribers?.Invoke(this, button));
|
||||
private void OnUp(IMouse _, MouseButton button) =>
|
||||
Invoke(() => _upSubscribers?.Invoke(this, button));
|
||||
private void OnClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _clickSubscribers?.Invoke(this, button, position));
|
||||
private void OnDoubleClick(IMouse _, MouseButton button, Vector2 position) =>
|
||||
Invoke(() => _doubleClickSubscribers?.Invoke(this, button, position));
|
||||
private void OnMove(IMouse _, Vector2 position) =>
|
||||
Invoke(() => _moveSubscribers?.Invoke(this, position));
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) =>
|
||||
Invoke(() => _scrollSubscribers?.Invoke(this, scroll));
|
||||
private void Invoke(Action callback) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
callback();
|
||||
});
|
||||
|
||||
private void Add<T>(ref T? subscribers, T value, int index, Action attach)
|
||||
where T : Delegate
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
subscribers = (T?)Delegate.Combine(subscribers, value);
|
||||
if (_attached[index]) return;
|
||||
_attached[index] = true;
|
||||
attach();
|
||||
}
|
||||
|
||||
private void RemoveSubscriber<T>(
|
||||
ref T? subscribers,
|
||||
T value,
|
||||
int index,
|
||||
Action detach)
|
||||
where T : Delegate
|
||||
{
|
||||
subscribers = (T?)Delegate.Remove(subscribers, value);
|
||||
if (subscribers is not null || !_attached[index]) return;
|
||||
detach();
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RemovePhysical(int index)
|
||||
{
|
||||
if (!_attached[index]) return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _inner.MouseDown -= _downRelay; _downSubscribers = null; break;
|
||||
case 1: _inner.MouseUp -= _upRelay; _upSubscribers = null; break;
|
||||
case 2: _inner.Click -= _clickRelay; _clickSubscribers = null; break;
|
||||
case 3: _inner.DoubleClick -= _doubleClickRelay; _doubleClickSubscribers = null; break;
|
||||
case 4: _inner.MouseMove -= _moveRelay; _moveSubscribers = null; break;
|
||||
case 5: _inner.Scroll -= _scrollRelay; _scrollSubscribers = null; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
using AcDream.App.Update;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -10,19 +12,10 @@ namespace AcDream.App.Input;
|
|||
/// state. The cached input result is presentation-only after the barrier: its
|
||||
/// one-shot commands are never replayed against a later authoritative cell.
|
||||
/// </remarks>
|
||||
public sealed class RetailLocalPlayerFrameController
|
||||
internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
|
||||
{
|
||||
private readonly Func<bool> _canPresentPlayer;
|
||||
private readonly Func<PlayerMovementController?> _getController;
|
||||
private readonly Func<MovementInput> _captureInput;
|
||||
private readonly Func<uint> _resolveLocalEntityId;
|
||||
private readonly Action _handleTargeting;
|
||||
private readonly Func<bool> _isHidden;
|
||||
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
|
||||
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
|
||||
private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
|
||||
private readonly Func<AcDream.Core.Physics.RetailObjectClockDisposition>
|
||||
_objectClockDisposition;
|
||||
private readonly ILocalPlayerFrameRuntime _runtime;
|
||||
private readonly IMovementInputSource _movementInput;
|
||||
|
||||
private AdvancedFrame? _advancedFrame;
|
||||
|
||||
|
|
@ -49,38 +42,12 @@ public sealed class RetailLocalPlayerFrameController
|
|||
};
|
||||
|
||||
public RetailLocalPlayerFrameController(
|
||||
Func<bool> canPresentPlayer,
|
||||
Func<PlayerMovementController?> getController,
|
||||
Func<MovementInput> captureInput,
|
||||
Func<uint> resolveLocalEntityId,
|
||||
Action handleTargeting,
|
||||
Func<bool> isHidden,
|
||||
Action<PlayerMovementController, MovementResult, bool> project,
|
||||
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
|
||||
Action<PlayerMovementController, bool> sendPostNetwork,
|
||||
Func<AcDream.Core.Physics.RetailObjectClockDisposition>?
|
||||
objectClockDisposition = null)
|
||||
ILocalPlayerFrameRuntime runtime,
|
||||
IMovementInputSource movementInput)
|
||||
{
|
||||
_canPresentPlayer = canPresentPlayer
|
||||
?? throw new ArgumentNullException(nameof(canPresentPlayer));
|
||||
_getController = getController
|
||||
?? throw new ArgumentNullException(nameof(getController));
|
||||
_captureInput = captureInput
|
||||
?? throw new ArgumentNullException(nameof(captureInput));
|
||||
_resolveLocalEntityId = resolveLocalEntityId
|
||||
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
|
||||
_handleTargeting = handleTargeting
|
||||
?? throw new ArgumentNullException(nameof(handleTargeting));
|
||||
_isHidden = isHidden
|
||||
?? throw new ArgumentNullException(nameof(isHidden));
|
||||
_project = project
|
||||
?? throw new ArgumentNullException(nameof(project));
|
||||
_sendPreNetwork = sendPreNetwork
|
||||
?? throw new ArgumentNullException(nameof(sendPreNetwork));
|
||||
_sendPostNetwork = sendPostNetwork
|
||||
?? throw new ArgumentNullException(nameof(sendPostNetwork));
|
||||
_objectClockDisposition = objectClockDisposition
|
||||
?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_movementInput = movementInput
|
||||
?? throw new ArgumentNullException(nameof(movementInput));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -90,15 +57,15 @@ public sealed class RetailLocalPlayerFrameController
|
|||
public void AdvanceBeforeNetwork(float deltaSeconds)
|
||||
{
|
||||
_advancedFrame = null;
|
||||
PlayerMovementController? controller = _getController();
|
||||
if (!_canPresentPlayer() || controller is null)
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return;
|
||||
|
||||
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
|
||||
{
|
||||
bool rejectedHidden = _isHidden();
|
||||
bool rejectedHidden = _runtime.IsHidden;
|
||||
MovementResult rejected = controller.CapturePresentationResult();
|
||||
_project(controller, rejected, rejectedHidden);
|
||||
_runtime.Project(controller, rejected, rejectedHidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
rejected,
|
||||
|
|
@ -108,10 +75,10 @@ public sealed class RetailLocalPlayerFrameController
|
|||
return;
|
||||
}
|
||||
|
||||
controller.LocalEntityId = _resolveLocalEntityId();
|
||||
controller.LocalEntityId = _runtime.ResolveLocalEntityId();
|
||||
|
||||
bool hidden = _isHidden();
|
||||
if (_objectClockDisposition()
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_runtime.ObjectClockDisposition
|
||||
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
|
||||
{
|
||||
// CPhysicsObj::update_object returns before advancing time for a
|
||||
|
|
@ -120,7 +87,7 @@ public sealed class RetailLocalPlayerFrameController
|
|||
// physics tick that retail did not run.
|
||||
controller.SuspendObjectUpdate(deltaSeconds);
|
||||
MovementResult paused = controller.CapturePresentationResult();
|
||||
_project(controller, paused, hidden);
|
||||
_runtime.Project(controller, paused, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
paused,
|
||||
|
|
@ -131,11 +98,14 @@ public sealed class RetailLocalPlayerFrameController
|
|||
}
|
||||
|
||||
MovementResult movement = hidden
|
||||
? controller.TickHidden(deltaSeconds, _handleTargeting)
|
||||
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
|
||||
? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting)
|
||||
: controller.Update(
|
||||
deltaSeconds,
|
||||
_movementInput.Capture(),
|
||||
_runtime.HandleTargeting);
|
||||
|
||||
_project(controller, movement, hidden);
|
||||
_sendPreNetwork(controller, movement, hidden);
|
||||
_runtime.Project(controller, movement, hidden);
|
||||
_runtime.SendPreNetwork(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
|
|
@ -151,18 +121,18 @@ public sealed class RetailLocalPlayerFrameController
|
|||
/// </summary>
|
||||
public void RunPostNetworkCommandPhase()
|
||||
{
|
||||
PlayerMovementController? controller = _getController();
|
||||
if (!_canPresentPlayer() || controller is null)
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return;
|
||||
|
||||
bool hidden = _isHidden();
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_advancedFrame is { } advanced
|
||||
&& ReferenceEquals(advanced.Controller, controller))
|
||||
{
|
||||
MovementResult movement = RefreshSpatialFields(
|
||||
advanced.Movement,
|
||||
controller);
|
||||
_project(controller, movement, hidden);
|
||||
_runtime.Project(controller, movement, hidden);
|
||||
_advancedFrame = new AdvancedFrame(
|
||||
controller,
|
||||
movement,
|
||||
|
|
@ -174,7 +144,7 @@ public sealed class RetailLocalPlayerFrameController
|
|||
return;
|
||||
}
|
||||
|
||||
_sendPostNetwork(controller, hidden);
|
||||
_runtime.SendPostNetwork(controller, hidden);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -185,11 +155,11 @@ public sealed class RetailLocalPlayerFrameController
|
|||
public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
|
||||
{
|
||||
frame = default;
|
||||
PlayerMovementController? controller = _getController();
|
||||
if (!_canPresentPlayer() || controller is null)
|
||||
PlayerMovementController? controller = _runtime.Controller;
|
||||
if (!_runtime.CanPresentPlayer || controller is null)
|
||||
return false;
|
||||
|
||||
bool hidden = _isHidden();
|
||||
bool hidden = _runtime.IsHidden;
|
||||
if (_advancedFrame is { } advanced
|
||||
&& ReferenceEquals(advanced.Controller, controller))
|
||||
{
|
||||
|
|
@ -204,7 +174,7 @@ public sealed class RetailLocalPlayerFrameController
|
|||
}
|
||||
|
||||
MovementResult initial = controller.CapturePresentationResult();
|
||||
_project(controller, initial, hidden);
|
||||
_runtime.Project(controller, initial, hidden);
|
||||
frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal file
139
src/AcDream.App/Input/RetainedUiGameplayBinding.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +1,218 @@
|
|||
using System;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IKeyboard"/> to the test-fakeable
|
||||
/// <see cref="IKeyboardSource"/> interface used by
|
||||
/// <see cref="InputDispatcher"/>. K.1a wiring: GameWindow constructs
|
||||
/// one of these wrapping the first keyboard from
|
||||
/// <c>IInputContext.Keyboards</c>; the dispatcher fans events out to
|
||||
/// subscribers without ever touching Silk types directly.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource
|
||||
internal interface IKeyboardEventSurface
|
||||
{
|
||||
void AddKeyDown(Action<Key> callback);
|
||||
void RemoveKeyDown(Action<Key> callback);
|
||||
void AddKeyUp(Action<Key> callback);
|
||||
void RemoveKeyUp(Action<Key> callback);
|
||||
bool IsKeyPressed(Key key);
|
||||
}
|
||||
|
||||
internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface
|
||||
{
|
||||
private readonly IKeyboard _keyboard;
|
||||
private Action<Key>? _keyDownCallback;
|
||||
private Action<Key>? _keyUpCallback;
|
||||
private readonly Action<IKeyboard, Key, int> _keyDown;
|
||||
private readonly Action<IKeyboard, Key, int> _keyUp;
|
||||
|
||||
public SilkKeyboardEventSurface(IKeyboard keyboard)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public void AddKeyDown(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyDownCallback = callback;
|
||||
_keyboard.KeyDown += _keyDown;
|
||||
}
|
||||
|
||||
public void RemoveKeyDown(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyDownCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyDown -= _keyDown;
|
||||
_keyDownCallback = null;
|
||||
}
|
||||
|
||||
public void AddKeyUp(Action<Key> callback)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
_keyUpCallback = callback;
|
||||
_keyboard.KeyUp += _keyUp;
|
||||
}
|
||||
|
||||
public void RemoveKeyUp(Action<Key> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_keyUpCallback, callback))
|
||||
return;
|
||||
_keyboard.KeyUp -= _keyUp;
|
||||
_keyUpCallback = null;
|
||||
}
|
||||
|
||||
public bool IsKeyPressed(Key key) => _keyboard.IsKeyPressed(key);
|
||||
|
||||
private void OnKeyDown(IKeyboard _, Key key, int __) =>
|
||||
_keyDownCallback?.Invoke(key);
|
||||
|
||||
private void OnKeyUp(IKeyboard _, Key key, int __) =>
|
||||
_keyUpCallback?.Invoke(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reversible Silk keyboard bridge. Production publishes the detached owner
|
||||
/// before Attach so a side-effecting event accessor can never orphan a
|
||||
/// subscription. Logical deactivation makes copied delegates inert while
|
||||
/// physical removal is retried.
|
||||
/// </summary>
|
||||
public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
|
||||
{
|
||||
private readonly IKeyboardEventSurface _surface;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<Key> _keyDown;
|
||||
private readonly Action<Key> _keyUp;
|
||||
private readonly bool[] _attached = new bool[2];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
|
||||
public event Action<Key, ModifierMask>? KeyDown;
|
||||
public event Action<Key, ModifierMask>? KeyUp;
|
||||
|
||||
public SilkKeyboardSource(IKeyboard keyboard)
|
||||
private SilkKeyboardSource(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
|
||||
_keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers());
|
||||
_keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers());
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_keyDown = OnKeyDown;
|
||||
_keyUp = OnKeyUp;
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key);
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboard keyboard,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkKeyboardEventSurface(keyboard),
|
||||
quiescence);
|
||||
|
||||
internal static SilkKeyboardSource CreateDetached(
|
||||
IKeyboardEventSurface surface,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Keyboard source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
|
||||
try
|
||||
{
|
||||
_attached[0] = true;
|
||||
_surface.AddKeyDown(_keyDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddKeyUp(_keyUp);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(Key key) => _surface.IsKeyPressed(key);
|
||||
|
||||
public ModifierMask CurrentModifiers => ReadModifiers();
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private void OnKeyDown(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyDown?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnKeyUp(Key key) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
KeyUp?.Invoke(key, ReadModifiers());
|
||||
});
|
||||
|
||||
private ModifierMask ReadModifiers()
|
||||
{
|
||||
ModifierMask m = ModifierMask.None;
|
||||
if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight))
|
||||
m |= ModifierMask.Shift;
|
||||
if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight))
|
||||
m |= ModifierMask.Ctrl;
|
||||
if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight))
|
||||
m |= ModifierMask.Alt;
|
||||
if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight))
|
||||
m |= ModifierMask.Win;
|
||||
return m;
|
||||
ModifierMask modifiers = ModifierMask.None;
|
||||
if (_surface.IsKeyPressed(Key.ShiftLeft) || _surface.IsKeyPressed(Key.ShiftRight))
|
||||
modifiers |= ModifierMask.Shift;
|
||||
if (_surface.IsKeyPressed(Key.ControlLeft) || _surface.IsKeyPressed(Key.ControlRight))
|
||||
modifiers |= ModifierMask.Ctrl;
|
||||
if (_surface.IsKeyPressed(Key.AltLeft) || _surface.IsKeyPressed(Key.AltRight))
|
||||
modifiers |= ModifierMask.Alt;
|
||||
if (_surface.IsKeyPressed(Key.SuperLeft) || _surface.IsKeyPressed(Key.SuperRight))
|
||||
modifiers |= ModifierMask.Win;
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("keyboard source callbacks",
|
||||
[
|
||||
new("key up", () => Remove(1)),
|
||||
new("key down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
if (index == 1)
|
||||
_surface.RemoveKeyUp(_keyUp);
|
||||
else
|
||||
_surface.RemoveKeyDown(_keyDown);
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Keyboard source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Keyboard source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Keyboard source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,295 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.App.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a Silk.NET <see cref="IMouse"/> + the active ImGui IO
|
||||
/// (for <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>)
|
||||
/// to the test-fakeable <see cref="IMouseSource"/> used by
|
||||
/// <see cref="InputDispatcher"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
|
||||
/// constructor takes two delegates so the App.Rendering layer can
|
||||
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> via whichever ImGui
|
||||
/// package is currently active without leaking the type onto the
|
||||
/// abstraction interface.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SilkMouseSource : IMouseSource
|
||||
internal interface IMouseEventSurface
|
||||
{
|
||||
void AddMouseDown(Action<MouseButton> callback);
|
||||
void RemoveMouseDown(Action<MouseButton> callback);
|
||||
void AddMouseUp(Action<MouseButton> callback);
|
||||
void RemoveMouseUp(Action<MouseButton> callback);
|
||||
void AddMouseMove(Action<Vector2> callback);
|
||||
void RemoveMouseMove(Action<Vector2> callback);
|
||||
void AddScroll(Action<float> callback);
|
||||
void RemoveScroll(Action<float> callback);
|
||||
bool IsButtonPressed(MouseButton button);
|
||||
}
|
||||
|
||||
internal sealed class SilkMouseEventSurface : IMouseEventSurface
|
||||
{
|
||||
private readonly IMouse _mouse;
|
||||
private readonly Func<bool> _wantCaptureMouse;
|
||||
private readonly Func<bool> _wantCaptureKeyboard;
|
||||
private Action<MouseButton>? _downCallback;
|
||||
private Action<MouseButton>? _upCallback;
|
||||
private Action<Vector2>? _moveCallback;
|
||||
private Action<float>? _scrollCallback;
|
||||
private readonly Action<IMouse, MouseButton> _down;
|
||||
private readonly Action<IMouse, MouseButton> _up;
|
||||
private readonly Action<IMouse, Vector2> _move;
|
||||
private readonly Action<IMouse, ScrollWheel> _scroll;
|
||||
|
||||
public SilkMouseEventSurface(IMouse mouse)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_down = OnDown;
|
||||
_up = OnUp;
|
||||
_move = OnMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
public void AddMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
_downCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseDown += _down;
|
||||
}
|
||||
|
||||
public void RemoveMouseDown(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_downCallback, callback)) return;
|
||||
_mouse.MouseDown -= _down;
|
||||
_downCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
_upCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseUp += _up;
|
||||
}
|
||||
|
||||
public void RemoveMouseUp(Action<MouseButton> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_upCallback, callback)) return;
|
||||
_mouse.MouseUp -= _up;
|
||||
_upCallback = null;
|
||||
}
|
||||
|
||||
public void AddMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
_moveCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.MouseMove += _move;
|
||||
}
|
||||
|
||||
public void RemoveMouseMove(Action<Vector2> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_moveCallback, callback)) return;
|
||||
_mouse.MouseMove -= _move;
|
||||
_moveCallback = null;
|
||||
}
|
||||
|
||||
public void AddScroll(Action<float> callback)
|
||||
{
|
||||
_scrollCallback = callback ?? throw new ArgumentNullException(nameof(callback));
|
||||
_mouse.Scroll += _scroll;
|
||||
}
|
||||
|
||||
public void RemoveScroll(Action<float> callback)
|
||||
{
|
||||
if (!ReferenceEquals(_scrollCallback, callback)) return;
|
||||
_mouse.Scroll -= _scroll;
|
||||
_scrollCallback = null;
|
||||
}
|
||||
|
||||
public bool IsButtonPressed(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
|
||||
private void OnDown(IMouse _, MouseButton button) => _downCallback?.Invoke(button);
|
||||
private void OnUp(IMouse _, MouseButton button) => _upCallback?.Invoke(button);
|
||||
private void OnMove(IMouse _, Vector2 position) => _moveCallback?.Invoke(position);
|
||||
private void OnScroll(IMouse _, ScrollWheel scroll) => _scrollCallback?.Invoke(scroll.Y);
|
||||
}
|
||||
|
||||
/// <summary>Reversible Silk mouse bridge with immediate logical cutoff.</summary>
|
||||
public sealed class SilkMouseSource : IMouseSource, IDisposable
|
||||
{
|
||||
private readonly IMouseEventSurface _surface;
|
||||
private readonly IInputCaptureSource _capture;
|
||||
private readonly HostQuiescenceGate _quiescence;
|
||||
private readonly Action<MouseButton> _mouseDown;
|
||||
private readonly Action<MouseButton> _mouseUp;
|
||||
private readonly Action<Vector2> _mouseMove;
|
||||
private readonly Action<float> _scroll;
|
||||
private readonly bool[] _attached = new bool[4];
|
||||
private ResourceShutdownTransaction? _detach;
|
||||
private bool _attachStarted;
|
||||
private int _disposeRequested;
|
||||
private int _active;
|
||||
private float _lastX;
|
||||
private float _lastY;
|
||||
private bool _haveLastPos;
|
||||
private bool _haveLastPosition;
|
||||
|
||||
public event Action<MouseButton, ModifierMask>? MouseDown;
|
||||
public event Action<MouseButton, ModifierMask>? MouseUp;
|
||||
public event Action<float, float>? MouseMove;
|
||||
public event Action<float>? Scroll;
|
||||
|
||||
/// <summary>Caller-supplied probe for the current modifier mask. Reused
|
||||
/// from the keyboard source so mouse events carry consistent modifier
|
||||
/// state.</summary>
|
||||
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
|
||||
public IKeyboardSource? ModifierSource { get; set; }
|
||||
|
||||
public SilkMouseSource(
|
||||
IMouse mouse,
|
||||
Func<bool> wantCaptureMouse,
|
||||
Func<bool> wantCaptureKeyboard)
|
||||
private SilkMouseSource(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence)
|
||||
{
|
||||
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
|
||||
_wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse));
|
||||
_wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
|
||||
ModifierSource = modifierSource;
|
||||
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
|
||||
_mouseDown = OnMouseDown;
|
||||
_mouseUp = OnMouseUp;
|
||||
_mouseMove = OnMouseMove;
|
||||
_scroll = OnScroll;
|
||||
}
|
||||
|
||||
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe());
|
||||
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe());
|
||||
_mouse.MouseMove += (_, pos) =>
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouse mouse,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(
|
||||
new SilkMouseEventSurface(mouse),
|
||||
capture,
|
||||
modifierSource,
|
||||
quiescence);
|
||||
|
||||
internal static SilkMouseSource CreateDetached(
|
||||
IMouseEventSurface surface,
|
||||
IInputCaptureSource capture,
|
||||
IKeyboardSource? modifierSource,
|
||||
HostQuiescenceGate quiescence) =>
|
||||
new(surface, capture, modifierSource, quiescence);
|
||||
|
||||
public bool IsDisposalComplete => _attached.All(static value => !value);
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(
|
||||
Volatile.Read(ref _disposeRequested) != 0,
|
||||
this);
|
||||
if (_attachStarted)
|
||||
throw new InvalidOperationException("Mouse source attachment has already started.");
|
||||
_attachStarted = true;
|
||||
try
|
||||
{
|
||||
float dx, dy;
|
||||
if (_haveLastPos)
|
||||
_attached[0] = true;
|
||||
_surface.AddMouseDown(_mouseDown);
|
||||
_attached[1] = true;
|
||||
_surface.AddMouseUp(_mouseUp);
|
||||
_attached[2] = true;
|
||||
_surface.AddMouseMove(_mouseMove);
|
||||
_attached[3] = true;
|
||||
_surface.AddScroll(_scroll);
|
||||
Volatile.Write(ref _active, 1);
|
||||
}
|
||||
catch (Exception attachError)
|
||||
{
|
||||
Deactivate();
|
||||
RollBackOrThrow(attachError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHeld(MouseButton button) => _surface.IsButtonPressed(button);
|
||||
public bool WantCaptureMouse => _capture.WantCaptureMouse;
|
||||
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
|
||||
|
||||
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposeRequested, 1);
|
||||
Deactivate();
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
|
||||
private ModifierMask ReadModifiers() =>
|
||||
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
|
||||
|
||||
private void OnMouseDown(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseDown?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnMouseUp(MouseButton button) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
MouseUp?.Invoke(button, ReadModifiers());
|
||||
});
|
||||
|
||||
private void OnMouseMove(Vector2 position) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) == 0)
|
||||
return;
|
||||
|
||||
float dx = 0f;
|
||||
float dy = 0f;
|
||||
if (_haveLastPosition)
|
||||
{
|
||||
dx = pos.X - _lastX;
|
||||
dy = pos.Y - _lastY;
|
||||
dx = position.X - _lastX;
|
||||
dy = position.Y - _lastY;
|
||||
}
|
||||
else
|
||||
{
|
||||
dx = 0f;
|
||||
dy = 0f;
|
||||
_haveLastPos = true;
|
||||
_haveLastPosition = true;
|
||||
}
|
||||
_lastX = pos.X;
|
||||
_lastY = pos.Y;
|
||||
|
||||
_lastX = position.X;
|
||||
_lastY = position.Y;
|
||||
MouseMove?.Invoke(dx, dy);
|
||||
};
|
||||
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
|
||||
});
|
||||
|
||||
private void OnScroll(float delta) =>
|
||||
_quiescence.Invoke(() =>
|
||||
{
|
||||
if (Volatile.Read(ref _active) != 0)
|
||||
Scroll?.Invoke(delta);
|
||||
});
|
||||
|
||||
private ResourceShutdownTransaction EnsureDetachTransaction() =>
|
||||
_detach ??= new ResourceShutdownTransaction(
|
||||
new ResourceShutdownStage("mouse source callbacks",
|
||||
[
|
||||
new("scroll", () => Remove(3)),
|
||||
new("move", () => Remove(2)),
|
||||
new("up", () => Remove(1)),
|
||||
new("down", () => Remove(0)),
|
||||
]));
|
||||
|
||||
private void Remove(int index)
|
||||
{
|
||||
if (!_attached[index])
|
||||
return;
|
||||
switch (index)
|
||||
{
|
||||
case 0: _surface.RemoveMouseDown(_mouseDown); break;
|
||||
case 1: _surface.RemoveMouseUp(_mouseUp); break;
|
||||
case 2: _surface.RemoveMouseMove(_mouseMove); break;
|
||||
case 3: _surface.RemoveScroll(_scroll); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(index));
|
||||
}
|
||||
_attached[index] = false;
|
||||
}
|
||||
|
||||
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
|
||||
private void RollBackOrThrow(Exception attachError)
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureDetachTransaction().CompleteOrThrow();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Mouse source registration and rollback both failed.",
|
||||
new InvalidOperationException(
|
||||
"Mouse source registration failed.", attachError),
|
||||
rollbackError);
|
||||
}
|
||||
|
||||
public bool WantCaptureMouse => _wantCaptureMouse();
|
||||
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
|
||||
throw new InvalidOperationException(
|
||||
"Mouse source registration failed and was rolled back.",
|
||||
attachError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
141
src/AcDream.App/Interaction/PlayerApproachCompletionState.cs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal interface IPlayerApproachCompletionSink
|
||||
{
|
||||
void PublishNaturalCompletion();
|
||||
void PublishCancellation(WeenieError error);
|
||||
}
|
||||
|
||||
internal interface IPlayerApproachCompletionLifetimeOwner
|
||||
{
|
||||
IPlayerApproachCompletionSink BeginControllerLifetime();
|
||||
void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime);
|
||||
}
|
||||
|
||||
internal interface IPlayerApproachTokenSource
|
||||
{
|
||||
bool TryBeginApproach(out PlayerApproachToken token);
|
||||
}
|
||||
|
||||
internal readonly record struct PlayerApproachToken(
|
||||
ulong ControllerLifetime,
|
||||
ulong ApproachGeneration);
|
||||
|
||||
internal readonly record struct PlayerApproachCompletion(
|
||||
PlayerApproachToken Token,
|
||||
bool IsNatural,
|
||||
WeenieError Error);
|
||||
|
||||
/// <summary>
|
||||
/// Same-thread mailbox between local MoveTo completion and the selection/
|
||||
/// item interaction phase. It deliberately owns no query, item, transport,
|
||||
/// window, or callback reference.
|
||||
/// </summary>
|
||||
internal sealed class PlayerApproachCompletionState
|
||||
: IPlayerApproachCompletionLifetimeOwner,
|
||||
IPlayerApproachTokenSource
|
||||
{
|
||||
private readonly Queue<PlayerApproachCompletion> _pending = new();
|
||||
private ControllerLifetime? _active;
|
||||
private ulong _nextLifetime;
|
||||
|
||||
public IPlayerApproachCompletionSink BeginControllerLifetime()
|
||||
{
|
||||
if (_active is not null)
|
||||
throw new InvalidOperationException(
|
||||
"A player approach-completion lifetime is already active.");
|
||||
|
||||
var lifetime = new ControllerLifetime(this, ++_nextLifetime);
|
||||
_active = lifetime;
|
||||
return lifetime;
|
||||
}
|
||||
|
||||
public void RetireControllerLifetime(IPlayerApproachCompletionSink lifetime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lifetime);
|
||||
if (!ReferenceEquals(_active, lifetime)
|
||||
|| lifetime is not ControllerLifetime retiring)
|
||||
return;
|
||||
|
||||
PurgeLifetime(retiring.LifetimeId);
|
||||
if (retiring.TryGetCurrentToken(out PlayerApproachToken token))
|
||||
{
|
||||
_pending.Enqueue(new PlayerApproachCompletion(
|
||||
token,
|
||||
IsNatural: false,
|
||||
WeenieError.ActionCancelled));
|
||||
}
|
||||
_active = null;
|
||||
}
|
||||
|
||||
public bool TryBeginApproach(out PlayerApproachToken token)
|
||||
{
|
||||
if (_active is null)
|
||||
{
|
||||
token = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
token = _active.BeginApproach();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryTake(out PlayerApproachCompletion completion) =>
|
||||
_pending.TryDequeue(out completion);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_active = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void Publish(
|
||||
ControllerLifetime lifetime,
|
||||
bool isNatural,
|
||||
WeenieError error)
|
||||
{
|
||||
if (!ReferenceEquals(_active, lifetime)
|
||||
|| !lifetime.TryGetCurrentToken(out PlayerApproachToken token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Enqueue(new PlayerApproachCompletion(token, isNatural, error));
|
||||
}
|
||||
|
||||
private void PurgeLifetime(ulong lifetimeId)
|
||||
{
|
||||
int retained = _pending.Count;
|
||||
for (int i = 0; i < retained; i++)
|
||||
{
|
||||
PlayerApproachCompletion completion = _pending.Dequeue();
|
||||
if (completion.Token.ControllerLifetime != lifetimeId)
|
||||
_pending.Enqueue(completion);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ControllerLifetime(
|
||||
PlayerApproachCompletionState owner,
|
||||
ulong lifetimeId) : IPlayerApproachCompletionSink
|
||||
{
|
||||
private ulong _approachGeneration;
|
||||
public ulong LifetimeId => lifetimeId;
|
||||
|
||||
public PlayerApproachToken BeginApproach() =>
|
||||
new(lifetimeId, ++_approachGeneration);
|
||||
|
||||
public bool TryGetCurrentToken(out PlayerApproachToken token)
|
||||
{
|
||||
token = new PlayerApproachToken(lifetimeId, _approachGeneration);
|
||||
return _approachGeneration != 0;
|
||||
}
|
||||
|
||||
public void PublishNaturalCompletion() =>
|
||||
owner.Publish(this, isNatural: true, WeenieError.None);
|
||||
|
||||
public void PublishCancellation(WeenieError error) =>
|
||||
owner.Publish(this, isNatural: false, error);
|
||||
}
|
||||
}
|
||||
77
src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
Normal file
77
src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal interface IPlayerInteractionMovementSink
|
||||
{
|
||||
/// <summary>
|
||||
/// Cancels any preceding MoveTo, invokes <paramref name="armAfterCancel"/>,
|
||||
/// then installs the new movement. The callback boundary lets the intent
|
||||
/// owner arm completion state before an already-facing TurnTo can finish
|
||||
/// synchronously, without letting the preceding cancellation clear it.
|
||||
/// </summary>
|
||||
bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs retail's client-side TurnToObject/MoveToObject prediction through
|
||||
/// the same MovementManager used by authoritative movement packets.
|
||||
/// </summary>
|
||||
internal sealed class PlayerInteractionMovementSink(
|
||||
Func<PlayerMovementController?> player,
|
||||
IPlayerApproachTokenSource approachTokens)
|
||||
: IPlayerInteractionMovementSink
|
||||
{
|
||||
private readonly Func<PlayerMovementController?> _player = player
|
||||
?? throw new ArgumentNullException(nameof(player));
|
||||
private readonly IPlayerApproachTokenSource _approachTokens = approachTokens
|
||||
?? throw new ArgumentNullException(nameof(approachTokens));
|
||||
|
||||
public bool BeginApproach(
|
||||
InteractionApproach approach,
|
||||
Action<PlayerApproachToken>? armAfterCancel = null)
|
||||
{
|
||||
PlayerMovementController? controller = _player();
|
||||
if (controller?.MoveTo is null)
|
||||
return false;
|
||||
|
||||
var parameters = new MovementParameters
|
||||
{
|
||||
DistanceToObject = approach.UseRadius,
|
||||
CanCharge = approach.CanCharge,
|
||||
};
|
||||
var movement = new MovementStruct
|
||||
{
|
||||
ObjectId = approach.Target.ServerGuid,
|
||||
TopLevelId = approach.Target.ServerGuid,
|
||||
Pos = new Position(
|
||||
approach.Player.CellId,
|
||||
approach.Target.Entity.Position,
|
||||
System.Numerics.Quaternion.Identity),
|
||||
Params = parameters,
|
||||
Type = approach.IsCloseRange
|
||||
? MovementType.TurnToObject
|
||||
: MovementType.MoveToObject,
|
||||
Radius = approach.TargetRadius,
|
||||
Height = approach.TargetHeight,
|
||||
};
|
||||
|
||||
// PerformMovement cancels at its head. Do it explicitly before the
|
||||
// intent is armed so cancellation of the preceding move cannot clear
|
||||
// the new request; the internal second call is then a retail no-op.
|
||||
controller.Movement.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
if (!_approachTokens.TryBeginApproach(out PlayerApproachToken token))
|
||||
return false;
|
||||
armAfterCancel?.Invoke(token);
|
||||
|
||||
// P1's wire MoveToObject store marks the command non-autonomous.
|
||||
// The speculative local install must do the same or the next raw-input
|
||||
// pump overwrites it before ACE's authoritative movement arrives.
|
||||
controller.SetLastMoveWasAutonomous(false);
|
||||
return controller.Movement.PerformMovement(movement) == WeenieError.None;
|
||||
}
|
||||
}
|
||||
504
src/AcDream.App/Interaction/SelectionInteractionController.cs
Normal file
504
src/AcDream.App/Interaction/SelectionInteractionController.cs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Ui;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
/// <summary>
|
||||
/// Owns world selection mutations and one-shot Use/PickUp interaction state.
|
||||
/// Read-only classification stays in <see cref="WorldSelectionQuery"/>;
|
||||
/// retained item policy stays in <see cref="ItemInteractionController"/>.
|
||||
/// </summary>
|
||||
internal sealed class SelectionInteractionController
|
||||
{
|
||||
private readonly SelectionState _selection;
|
||||
private readonly IWorldSelectionQuery _query;
|
||||
private readonly ItemInteractionController _items;
|
||||
private readonly ISelectionInteractionTransport _transport;
|
||||
private readonly IPlayerInteractionMovementSink _movement;
|
||||
private readonly OutboundInteractionQueue _outbound = new();
|
||||
private readonly PlayerApproachCompletionState _approachCompletions;
|
||||
private readonly Action<string>? _toast;
|
||||
private PendingPostArrivalAction? _pendingPostArrival;
|
||||
|
||||
private readonly record struct QueuedInteractionIdentity(
|
||||
uint ServerGuid,
|
||||
uint? LocalEntityId,
|
||||
ClientObject? ClientObject);
|
||||
|
||||
private readonly record struct PendingPostArrivalAction(
|
||||
uint ServerGuid,
|
||||
uint LocalEntityId,
|
||||
bool IsPickup,
|
||||
uint DestinationContainerId,
|
||||
int Placement,
|
||||
ulong PendingPlacementToken,
|
||||
PlayerApproachToken ApproachToken);
|
||||
|
||||
public SelectionInteractionController(
|
||||
SelectionState selection,
|
||||
IWorldSelectionQuery query,
|
||||
ItemInteractionController items,
|
||||
ISelectionInteractionTransport transport,
|
||||
IPlayerInteractionMovementSink movement,
|
||||
Action<string>? toast = null,
|
||||
PlayerApproachCompletionState? approachCompletions = null)
|
||||
{
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_query = query ?? throw new ArgumentNullException(nameof(query));
|
||||
_items = items ?? throw new ArgumentNullException(nameof(items));
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
|
||||
_toast = toast;
|
||||
_approachCompletions = approachCompletions
|
||||
?? new PlayerApproachCompletionState();
|
||||
}
|
||||
|
||||
public bool HandleInputAction(InputAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.SelectionClosestMonster:
|
||||
SelectClosestCombatTarget(showToast: true);
|
||||
return true;
|
||||
case InputAction.SelectionPreviousSelection:
|
||||
_selection.SelectPrevious();
|
||||
return true;
|
||||
case InputAction.SelectLeft:
|
||||
PickAndStoreSelection(useImmediately: false);
|
||||
return true;
|
||||
case InputAction.SelectDblLeft:
|
||||
PickAndStoreSelection(useImmediately: true);
|
||||
return true;
|
||||
case InputAction.UseSelected:
|
||||
UseCurrentSelection();
|
||||
return true;
|
||||
case InputAction.SelectionPickUp:
|
||||
if (_selection.SelectedObjectId is uint pickupTarget)
|
||||
{
|
||||
EnqueueIdentityBound(
|
||||
pickupTarget,
|
||||
requireLiveEntity: true,
|
||||
guid =>
|
||||
{
|
||||
if (ValidatePickupTarget(guid, showToast: true))
|
||||
_items.PlaceWorldItemInBackpack(guid);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_toast?.Invoke("Nothing selected");
|
||||
}
|
||||
return true;
|
||||
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
|
||||
_items.CancelTargetMode();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
=> _query.PickAtCursor(includeSelf);
|
||||
|
||||
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
|
||||
if (target != 0u)
|
||||
_query.BeginLightingPulse(target);
|
||||
_items.PlaceIn3D(payload, target);
|
||||
}
|
||||
|
||||
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
|
||||
{
|
||||
if (_selection.SelectedObjectId is { } selected
|
||||
&& _query.IsHostileMonster(selected))
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
return autoTarget ? SelectClosestCombatTarget(showToast: false) : null;
|
||||
}
|
||||
|
||||
public uint? SelectClosestCombatTarget(bool showToast)
|
||||
{
|
||||
ClosestCombatTarget? closest = _query.FindClosestHostileMonster();
|
||||
uint? bestGuid = closest?.ServerGuid;
|
||||
if (bestGuid is { } selected)
|
||||
_selection.Select(selected, SelectionChangeSource.Keyboard);
|
||||
else
|
||||
_selection.Clear(SelectionChangeSource.Keyboard);
|
||||
|
||||
if (bestGuid is { } guid)
|
||||
{
|
||||
string label = _query.Describe(guid);
|
||||
float distance = MathF.Sqrt(closest!.Value.DistanceSquared);
|
||||
Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}");
|
||||
if (showToast)
|
||||
_toast?.Invoke($"Target {label}");
|
||||
}
|
||||
else if (showToast)
|
||||
{
|
||||
_toast?.Invoke("No monster target");
|
||||
Console.WriteLine("combat: no creature target found");
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
public void PickAndStoreSelection(bool useImmediately)
|
||||
{
|
||||
uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive);
|
||||
if (picked is not uint guid)
|
||||
{
|
||||
if (!_items.IsAnyTargetModeActive)
|
||||
_toast?.Invoke("Nothing to select");
|
||||
return;
|
||||
}
|
||||
|
||||
// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
|
||||
// @ 0x004E5AD0 pulses before select/use/target-mode branching.
|
||||
_query.BeginLightingPulse(guid);
|
||||
if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive)
|
||||
return;
|
||||
|
||||
_selection.Select(guid, SelectionChangeSource.World);
|
||||
string label = _query.Describe(guid);
|
||||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||||
_toast?.Invoke($"Selected: {label}");
|
||||
if (useImmediately)
|
||||
EnqueueIdentityBound(
|
||||
guid,
|
||||
requireLiveEntity: true,
|
||||
selected =>
|
||||
{
|
||||
_items.ActivateItem(selected);
|
||||
});
|
||||
}
|
||||
|
||||
public void UseCurrentSelection()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not uint selected)
|
||||
{
|
||||
_toast?.Invoke("Nothing selected");
|
||||
return;
|
||||
}
|
||||
EnqueueIdentityBound(
|
||||
selected,
|
||||
requireLiveEntity: false,
|
||||
guid =>
|
||||
{
|
||||
_items.UseSelectedOrEnterMode(guid);
|
||||
});
|
||||
}
|
||||
|
||||
public void SendUse(uint serverGuid)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
if (!_transport.IsInWorld)
|
||||
{
|
||||
_toast?.Invoke("Not in world");
|
||||
return;
|
||||
}
|
||||
if (!_query.IsUseable(serverGuid)
|
||||
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (approach.IsCloseRange)
|
||||
{
|
||||
var pending = new PendingPostArrivalAction(
|
||||
serverGuid,
|
||||
approach.Target.LocalEntityId,
|
||||
IsPickup: false,
|
||||
DestinationContainerId: 0u,
|
||||
Placement: 0,
|
||||
PendingPlacementToken: 0u,
|
||||
ApproachToken: default);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
token => _pendingPostArrival = pending with { ApproachToken = token });
|
||||
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
|
||||
CancelPendingApproach();
|
||||
return;
|
||||
}
|
||||
|
||||
_movement.BeginApproach(approach);
|
||||
if (_transport.TrySendUse(serverGuid, out uint sequence))
|
||||
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
|
||||
}
|
||||
|
||||
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
if (!_transport.IsInWorld)
|
||||
{
|
||||
_toast?.Invoke("Not in world");
|
||||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
||||
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
||||
{
|
||||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
out PendingBackpackPlacement pendingPlacement)
|
||||
? pendingPlacement.Token
|
||||
: 0u;
|
||||
if (!IsCurrentPickupPresentation(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (approach.IsCloseRange)
|
||||
{
|
||||
var pending = new PendingPostArrivalAction(
|
||||
itemGuid,
|
||||
approach.Target.LocalEntityId,
|
||||
IsPickup: true,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken,
|
||||
ApproachToken: default);
|
||||
bool started = _movement.BeginApproach(
|
||||
approach,
|
||||
token => _pendingPostArrival = pending with { ApproachToken = token });
|
||||
if (!started && _pendingPostArrival?.ServerGuid == pending.ServerGuid)
|
||||
CancelPendingApproach();
|
||||
else if (!started)
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
return;
|
||||
}
|
||||
|
||||
_movement.BeginApproach(approach);
|
||||
if (!IsCurrentPickupPresentation(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
uint sequence = 0u;
|
||||
if (_items.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
itemGuid,
|
||||
() => _transport.TrySendPickup(
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
out sequence),
|
||||
pendingPlacementToken))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
|
||||
}
|
||||
else
|
||||
{
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
|
||||
public void OnNaturalMoveToComplete()
|
||||
=> HandleNaturalMoveToComplete();
|
||||
|
||||
private void HandleNaturalMoveToComplete()
|
||||
{
|
||||
if (_pendingPostArrival is not { } pending)
|
||||
return;
|
||||
_pendingPostArrival = null;
|
||||
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
|
||||
{
|
||||
if (pending.IsPickup)
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.IsPickup)
|
||||
{
|
||||
if (!IsCurrentPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
pending.Placement,
|
||||
pending.PendingPlacementToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!_items.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
pending.ServerGuid,
|
||||
() => _transport.TrySendPickup(
|
||||
pending.ServerGuid,
|
||||
pending.DestinationContainerId,
|
||||
pending.Placement,
|
||||
out _),
|
||||
pending.PendingPlacementToken))
|
||||
{
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_transport.TrySendUse(pending.ServerGuid, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void DrainOutbound()
|
||||
{
|
||||
while (_approachCompletions.TryTake(out PlayerApproachCompletion completion))
|
||||
{
|
||||
if (_pendingPostArrival?.ApproachToken != completion.Token)
|
||||
continue;
|
||||
if (completion.IsNatural)
|
||||
HandleNaturalMoveToComplete();
|
||||
else
|
||||
CancelPendingApproach();
|
||||
}
|
||||
_outbound.Drain();
|
||||
}
|
||||
|
||||
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
|
||||
|
||||
public void OnEntityHidden(uint serverGuid)
|
||||
{
|
||||
if (_pendingPostArrival?.ServerGuid == serverGuid)
|
||||
CancelPendingApproach();
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.Cleared);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
if (_pendingPostArrival is { } pending
|
||||
&& pending.ServerGuid == record.ServerGuid
|
||||
&& pending.LocalEntityId == record.LocalEntityId)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
}
|
||||
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.System,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
List<Exception> failures = [];
|
||||
try { CancelPendingApproach(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _items.ResetSession(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _selection.Reset(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _outbound.Clear(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
try { _approachCompletions.Clear(); }
|
||||
catch (Exception error) { failures.Add(error); }
|
||||
_pendingPostArrival = null;
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new AggregateException(
|
||||
"One or more selection-interaction reset stages failed.",
|
||||
failures);
|
||||
}
|
||||
|
||||
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
|
||||
{
|
||||
if (_query.IsCreature(serverGuid))
|
||||
{
|
||||
if (showToast)
|
||||
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
|
||||
return false;
|
||||
}
|
||||
if (_query.IsPickupable(serverGuid))
|
||||
return true;
|
||||
if (showToast)
|
||||
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EnqueueIdentityBound(
|
||||
uint serverGuid,
|
||||
bool requireLiveEntity,
|
||||
Action<uint> action)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
|
||||
? localId
|
||||
: null;
|
||||
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
|
||||
? captured
|
||||
: null;
|
||||
if ((requireLiveEntity && localEntityId is null)
|
||||
|| (localEntityId is null && item is null))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
|
||||
_outbound.Enqueue(() =>
|
||||
{
|
||||
if (IsCurrent(identity))
|
||||
action(identity.ServerGuid);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsCurrent(QueuedInteractionIdentity identity)
|
||||
=> (identity.LocalEntityId is not uint localId
|
||||
|| _query.IsCurrent(identity.ServerGuid, localId))
|
||||
&& (identity.ClientObject is not { } item
|
||||
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
|
||||
|
||||
private void CancelPendingApproach()
|
||||
{
|
||||
if (_pendingPostArrival is not { } pending)
|
||||
return;
|
||||
_pendingPostArrival = null;
|
||||
if (pending.IsPickup)
|
||||
CancelPickupPresentation(
|
||||
pending.ServerGuid,
|
||||
pending.PendingPlacementToken);
|
||||
}
|
||||
|
||||
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
|
||||
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
|
||||
|
||||
private bool IsCurrentPickupPresentation(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
ulong token)
|
||||
=> token != 0u
|
||||
&& _items.TryGetPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
out PendingBackpackPlacement pending)
|
||||
&& pending.Token == token
|
||||
&& pending.ContainerId == destinationContainerId
|
||||
&& pending.Placement == placement;
|
||||
}
|
||||
62
src/AcDream.App/Interaction/SelectionInteractionTransport.cs
Normal file
62
src/AcDream.App/Interaction/SelectionInteractionTransport.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal interface ISelectionInteractionTransport
|
||||
{
|
||||
bool IsInWorld { get; }
|
||||
bool TrySendUse(uint serverGuid, out uint sequence);
|
||||
bool TrySendPickup(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
out uint sequence);
|
||||
}
|
||||
|
||||
internal sealed class WorldSessionSelectionInteractionTransport(
|
||||
Func<WorldSession?> session)
|
||||
: ISelectionInteractionTransport
|
||||
{
|
||||
private readonly Func<WorldSession?> _session = session
|
||||
?? throw new ArgumentNullException(nameof(session));
|
||||
|
||||
public bool IsInWorld =>
|
||||
_session()?.CurrentState == WorldSession.State.InWorld;
|
||||
|
||||
public bool TrySendUse(uint serverGuid, out uint sequence)
|
||||
{
|
||||
WorldSession? live = _session();
|
||||
if (live?.CurrentState != WorldSession.State.InWorld)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
sequence = live.NextGameActionSequence();
|
||||
live.SendGameAction(InteractRequests.BuildUse(sequence, serverGuid));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TrySendPickup(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
out uint sequence)
|
||||
{
|
||||
WorldSession? live = _session();
|
||||
if (live?.CurrentState != WorldSession.State.InWorld)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
sequence = live.NextGameActionSequence();
|
||||
live.SendGameAction(InteractRequests.BuildPickUp(
|
||||
sequence,
|
||||
itemGuid,
|
||||
destinationContainerId,
|
||||
placement));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
377
src/AcDream.App/Interaction/WorldSelectionQuery.cs
Normal file
377
src/AcDream.App/Interaction/WorldSelectionQuery.cs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Interaction;
|
||||
|
||||
internal readonly record struct SelectionCameraSnapshot(
|
||||
Matrix4x4 View,
|
||||
Matrix4x4 Projection,
|
||||
Vector2 Viewport);
|
||||
|
||||
internal readonly record struct PlayerInteractionPose(uint CellId, Vector3 Position);
|
||||
|
||||
internal readonly record struct WorldInteractionTarget(
|
||||
uint ServerGuid,
|
||||
uint LocalEntityId,
|
||||
WorldEntity Entity);
|
||||
|
||||
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
|
||||
|
||||
internal readonly record struct InteractionApproach(
|
||||
WorldInteractionTarget Target,
|
||||
PlayerInteractionPose Player,
|
||||
float UseRadius,
|
||||
bool IsCloseRange,
|
||||
bool CanCharge,
|
||||
float TargetRadius,
|
||||
float TargetHeight);
|
||||
|
||||
internal interface IWorldSelectionQuery
|
||||
{
|
||||
uint? PickAtCursor(bool includeSelf);
|
||||
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
|
||||
void BeginLightingPulse(uint serverGuid);
|
||||
bool TryCaptureIdentity(uint serverGuid, out uint localEntityId);
|
||||
bool IsCurrent(uint serverGuid, uint localEntityId);
|
||||
string Describe(uint serverGuid);
|
||||
bool IsCreature(uint serverGuid);
|
||||
bool IsHostileMonster(uint serverGuid);
|
||||
ClosestCombatTarget? FindClosestHostileMonster();
|
||||
bool IsUseable(uint serverGuid);
|
||||
bool IsPickupable(uint serverGuid);
|
||||
bool TryGetApproach(uint serverGuid, out InteractionApproach approach);
|
||||
Vector3? GetCombatCameraTargetPoint(uint serverGuid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only owner for retail world selection and interaction classification.
|
||||
/// It consumes the canonical live-entity runtime but never mutates selection,
|
||||
/// movement, inventory, or network state.
|
||||
/// </summary>
|
||||
internal sealed class WorldSelectionQuery : IWorldSelectionQuery
|
||||
{
|
||||
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
private const uint StuckObjectFlag = 0x0004u;
|
||||
private const float DefaultUseRadius = 0.6f;
|
||||
private const float CreatureUseRadius = 3f;
|
||||
private const float LargeObjectUseRadius = 2f;
|
||||
private const float AceCanChargeDistance = 7.5f;
|
||||
|
||||
private const uint SmallItemMask =
|
||||
(uint)(ItemType.MeleeWeapon
|
||||
| ItemType.Armor
|
||||
| ItemType.Clothing
|
||||
| ItemType.Jewelry
|
||||
| ItemType.Food
|
||||
| ItemType.Money
|
||||
| ItemType.Misc
|
||||
| ItemType.MissileWeapon
|
||||
| ItemType.Container
|
||||
| ItemType.Gem
|
||||
| ItemType.SpellComponents
|
||||
| ItemType.Writable
|
||||
| ItemType.Key
|
||||
| ItemType.Caster);
|
||||
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly RetailSelectionScene _selectionScene;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<SelectionCameraSnapshot> _camera;
|
||||
private readonly Func<Vector2> _cursor;
|
||||
private readonly Func<PlayerInteractionPose?> _playerPose;
|
||||
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
|
||||
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
|
||||
|
||||
public WorldSelectionQuery(
|
||||
LiveEntityRuntime liveEntities,
|
||||
ClientObjectTable objects,
|
||||
RetailSelectionScene selectionScene,
|
||||
Func<uint> playerGuid,
|
||||
Func<SelectionCameraSnapshot> camera,
|
||||
Func<Vector2> cursor,
|
||||
Func<PlayerInteractionPose?> playerPose,
|
||||
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
|
||||
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_selectionScene = selectionScene ?? throw new ArgumentNullException(nameof(selectionScene));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
|
||||
_playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose));
|
||||
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
|
||||
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
|
||||
}
|
||||
|
||||
public uint? PickAtCursor(bool includeSelf)
|
||||
{
|
||||
Vector2 cursor = _cursor();
|
||||
return PickAt(cursor.X, cursor.Y, includeSelf);
|
||||
}
|
||||
|
||||
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
|
||||
{
|
||||
SelectionCameraSnapshot camera = _camera();
|
||||
RetailSelectionHit? hit = _selectionScene.Pick(
|
||||
mouseX,
|
||||
mouseY,
|
||||
camera.Viewport,
|
||||
camera.View,
|
||||
camera.Projection,
|
||||
includeSelf ? 0u : _playerGuid());
|
||||
return hit is { } found
|
||||
&& _liveEntities.TryGetInteractionEligibleRecord(
|
||||
found.ServerGuid,
|
||||
found.LocalEntityId,
|
||||
out _)
|
||||
? found.ServerGuid
|
||||
: null;
|
||||
}
|
||||
|
||||
public void BeginLightingPulse(uint serverGuid)
|
||||
{
|
||||
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
|
||||
}
|
||||
|
||||
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
|
||||
{
|
||||
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
localEntityId = target.LocalEntityId;
|
||||
return true;
|
||||
}
|
||||
|
||||
localEntityId = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetInteractionTarget(
|
||||
uint serverGuid,
|
||||
out WorldInteractionTarget target)
|
||||
{
|
||||
if (_liveEntities.TryGetInteractionEligibleRecord(
|
||||
serverGuid,
|
||||
out LiveEntityRecord record)
|
||||
&& record.WorldEntity is { } entity)
|
||||
{
|
||||
target = new WorldInteractionTarget(serverGuid, entity.Id, entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
target = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool IsCurrent(WorldInteractionTarget target)
|
||||
=> IsCurrent(target.ServerGuid, target.LocalEntityId);
|
||||
|
||||
public bool IsCurrent(uint serverGuid, uint localEntityId)
|
||||
=> _liveEntities.TryGetInteractionEligibleRecord(serverGuid, localEntityId, out _);
|
||||
|
||||
public ItemType GetItemType(uint serverGuid)
|
||||
=> _objects.Get(serverGuid)?.Type ?? ItemType.None;
|
||||
|
||||
public string Describe(uint serverGuid)
|
||||
{
|
||||
string? name = _objects.Get(serverGuid)?.Name;
|
||||
return string.IsNullOrWhiteSpace(name) ? $"0x{serverGuid:X8}" : name;
|
||||
}
|
||||
|
||||
public bool IsCreature(uint serverGuid)
|
||||
{
|
||||
if (serverGuid == _playerGuid()
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_liveEntities.TryGetAnimationRuntime(target.LocalEntityId, out var animation)
|
||||
&& animation.CurrentMotion == MotionCommand.Dead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
public bool IsHostileMonster(uint serverGuid)
|
||||
=> IsCreature(serverGuid)
|
||||
&& CombatTargetPolicy.IsHostileMonster(
|
||||
_playerGuid(),
|
||||
_objects.Get(_playerGuid()),
|
||||
_objects.Get(serverGuid));
|
||||
|
||||
public bool ShouldShowHealth(uint serverGuid)
|
||||
=> SelectedObjectHealthPolicy.ShouldQueryHealth(
|
||||
_playerGuid(),
|
||||
_objects.Get(_playerGuid()),
|
||||
_objects.Get(serverGuid));
|
||||
|
||||
public ClosestCombatTarget? FindClosestHostileMonster()
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player))
|
||||
return null;
|
||||
|
||||
ClosestCombatTarget? best = null;
|
||||
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
|
||||
{
|
||||
if (!IsHostileMonster(guid))
|
||||
continue;
|
||||
float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position);
|
||||
if (best is null || distanceSquared < best.Value.DistanceSquared)
|
||||
best = new ClosestCombatTarget(guid, distanceSquared);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
|
||||
=> IsHostileMonster(serverGuid)
|
||||
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
||||
? target.Entity.Position
|
||||
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)
|
||||
: null;
|
||||
|
||||
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out _)
|
||||
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
uint pwdBits = _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
? spawn.ObjectDescriptionFlags ?? 0u
|
||||
: 0u;
|
||||
return new VividTargetInfo(center, radius, (uint)GetItemType(serverGuid), pwdBits);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SmartBox::GetObjectBoundingBox @ 0x00452E20 delegates to
|
||||
/// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit
|
||||
/// fallback when Setup authored no sphere.
|
||||
/// </summary>
|
||||
public bool TryGetSelectionSphere(
|
||||
uint serverGuid,
|
||||
out Vector3 worldCenter,
|
||||
out float worldRadius)
|
||||
{
|
||||
worldCenter = default;
|
||||
worldRadius = 0f;
|
||||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity))
|
||||
return false;
|
||||
|
||||
worldCenter = entity.Position;
|
||||
worldRadius = 0.1f;
|
||||
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| spawn.SetupTableId is not uint setupId
|
||||
|| _selectionSphere(setupId) is not { } sphere
|
||||
|| sphere.Radius <= 1e-4f)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||||
Vector3 localCenter = sphere.Origin * scale;
|
||||
worldCenter = entity.Position + Vector3.Transform(localCenter, entity.Rotation);
|
||||
worldRadius = sphere.Radius * scale;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
|
||||
public bool IsUseable(uint serverGuid)
|
||||
{
|
||||
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
|
||||
{
|
||||
if (spawn.Useability is uint useability)
|
||||
return useability is not 0u and not 1u;
|
||||
if (((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u)
|
||||
return true;
|
||||
}
|
||||
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
|
||||
}
|
||||
|
||||
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>
|
||||
public bool IsPickupable(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| ((spawn.ObjectDescriptionFlags ?? 0u) & StuckObjectFlag) != 0u)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u;
|
||||
}
|
||||
|
||||
public bool TryGetApproach(
|
||||
uint serverGuid,
|
||||
out InteractionApproach approach)
|
||||
{
|
||||
if (_playerPose() is not { } player
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
|
||||
{
|
||||
approach = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
float useRadius = GetUseRadius(serverGuid);
|
||||
float dx = target.Entity.Position.X - player.Position.X;
|
||||
float dy = target.Entity.Position.Y - player.Position.Y;
|
||||
float distanceSquared = dx * dx + dy * dy;
|
||||
(float radius, float height) = _setupCylinder(serverGuid, target.Entity);
|
||||
approach = new InteractionApproach(
|
||||
target,
|
||||
player,
|
||||
useRadius,
|
||||
distanceSquared <= useRadius * useRadius,
|
||||
distanceSquared >= AceCanChargeDistance * AceCanChargeDistance,
|
||||
radius,
|
||||
height);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsWithinExternalContainerUseRange(uint serverGuid)
|
||||
{
|
||||
if (_playerPose() is not { } playerPose
|
||||
|| !_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player)
|
||||
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|
||||
|| !_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
|| spawn.UseRadius is not > 0f)
|
||||
{
|
||||
// The server remains authoritative while render projection is absent.
|
||||
return true;
|
||||
}
|
||||
|
||||
var playerCylinder = _setupCylinder(_playerGuid(), player);
|
||||
var targetCylinder = _setupCylinder(serverGuid, target.Entity);
|
||||
return ObjectRangeMath.ObjectsInRange(
|
||||
playerPose.Position,
|
||||
playerCylinder.Radius,
|
||||
playerCylinder.Height,
|
||||
target.Entity.Position,
|
||||
targetCylinder.Radius,
|
||||
targetCylinder.Height,
|
||||
spawn.UseRadius.Value,
|
||||
useRadii: true,
|
||||
ignoreZDelta: false);
|
||||
}
|
||||
|
||||
private float GetUseRadius(uint serverGuid)
|
||||
{
|
||||
if ((GetItemType(serverGuid) & ItemType.Creature) != 0)
|
||||
return CreatureUseRadius;
|
||||
return _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|
||||
&& ((spawn.ObjectDescriptionFlags ?? 0u) & LargeUseObjectFlags) != 0u
|
||||
? LargeObjectUseRadius
|
||||
: DefaultUseRadius;
|
||||
}
|
||||
}
|
||||
11
src/AcDream.App/Net/ILiveInWorldSource.cs
Normal file
11
src/AcDream.App/Net/ILiveInWorldSource.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace AcDream.App.Net;
|
||||
|
||||
internal interface ILiveInWorldSource
|
||||
{
|
||||
bool IsInWorld { get; }
|
||||
}
|
||||
|
||||
internal interface ILiveWorldSessionSource
|
||||
{
|
||||
AcDream.Core.Net.WorldSession? CurrentSession { get; }
|
||||
}
|
||||
97
src/AcDream.App/Net/LiveEntitySessionController.cs
Normal file
97
src/AcDream.App/Net/LiveEntitySessionController.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Routes one live session's entity packets through the non-reentrant retail
|
||||
/// inbound envelope without retaining the window host.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntitySessionController
|
||||
{
|
||||
private readonly RetailInboundEventDispatcher _inbound;
|
||||
private readonly LiveEntityHydrationController _hydration;
|
||||
private readonly LiveEntityNetworkUpdateController _updates;
|
||||
private readonly ILocalPlayerTeleportNetworkSink _teleport;
|
||||
private readonly EntityEffectController _effects;
|
||||
|
||||
public LiveEntitySessionController(
|
||||
RetailInboundEventDispatcher inbound,
|
||||
LiveEntityHydrationController hydration,
|
||||
LiveEntityNetworkUpdateController updates,
|
||||
ILocalPlayerTeleportNetworkSink teleport,
|
||||
EntityEffectController effects)
|
||||
{
|
||||
_inbound = inbound ?? throw new ArgumentNullException(nameof(inbound));
|
||||
_hydration = hydration ?? throw new ArgumentNullException(nameof(hydration));
|
||||
_updates = updates ?? throw new ArgumentNullException(nameof(updates));
|
||||
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
|
||||
_effects = effects ?? throw new ArgumentNullException(nameof(effects));
|
||||
}
|
||||
|
||||
public LiveEntitySessionSink CreateSink() => new(
|
||||
OnSpawned,
|
||||
OnDeleted,
|
||||
OnPickedUp,
|
||||
OnMotionUpdated,
|
||||
OnPositionUpdated,
|
||||
OnVectorUpdated,
|
||||
OnStateUpdated,
|
||||
OnParentUpdated,
|
||||
OnTeleportStarted,
|
||||
OnAppearanceUpdated,
|
||||
OnPlayPhysicsScript,
|
||||
OnPlayPhysicsScriptType);
|
||||
|
||||
private void OnSpawned(WorldSession.EntitySpawn value) =>
|
||||
_inbound.Run(_hydration, value,
|
||||
static (owner, message) => owner.OnCreate(message));
|
||||
|
||||
private void OnDeleted(DeleteObject.Parsed value) =>
|
||||
_inbound.Run(_hydration, value,
|
||||
static (owner, message) => owner.OnDelete(message));
|
||||
|
||||
private void OnPickedUp(PickupEvent.Parsed value) =>
|
||||
_inbound.Run(_hydration, value,
|
||||
static (owner, message) => owner.OnPickup(message));
|
||||
|
||||
private void OnMotionUpdated(WorldSession.EntityMotionUpdate value) =>
|
||||
_inbound.Run(_updates, value,
|
||||
static (owner, message) => owner.OnMotion(message));
|
||||
|
||||
private void OnPositionUpdated(WorldSession.EntityPositionUpdate value) =>
|
||||
_inbound.Run(_updates, value,
|
||||
static (owner, message) => owner.OnPosition(message));
|
||||
|
||||
private void OnVectorUpdated(VectorUpdate.Parsed value) =>
|
||||
_inbound.Run(_updates, value,
|
||||
static (owner, message) => owner.OnVector(message));
|
||||
|
||||
private void OnStateUpdated(SetState.Parsed value) =>
|
||||
_inbound.Run(_updates, value,
|
||||
static (owner, message) => owner.OnState(message));
|
||||
|
||||
private void OnParentUpdated(ParentEvent.Parsed value) =>
|
||||
_inbound.Run(_hydration, value,
|
||||
static (owner, message) => owner.OnParent(message));
|
||||
|
||||
private void OnTeleportStarted(uint value) =>
|
||||
_inbound.Run(_teleport, value,
|
||||
static (owner, message) => owner.OnTeleportStarted(message));
|
||||
|
||||
private void OnAppearanceUpdated(ObjDescEvent.Parsed value) =>
|
||||
_inbound.Run(_hydration, value,
|
||||
static (owner, message) => owner.OnAppearance(message));
|
||||
|
||||
private void OnPlayPhysicsScript(PlayPhysicsScript value) =>
|
||||
_inbound.Run(_effects, value,
|
||||
static (owner, message) => owner.HandleDirect(message));
|
||||
|
||||
private void OnPlayPhysicsScriptType(PlayPhysicsScriptType value) =>
|
||||
_inbound.Run(_effects, value,
|
||||
static (owner, message) => owner.HandleTyped(message));
|
||||
}
|
||||
326
src/AcDream.App/Net/LiveSessionCommandRouter.cs
Normal file
326
src/AcDream.App/Net/LiveSessionCommandRouter.cs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal sealed record LiveSessionCommandBindings(
|
||||
ClientCommandController.Bindings ClientCommands,
|
||||
ChatLog Chat,
|
||||
TurbineChatState TurbineChat,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<string> SendTalk,
|
||||
Action<string, string> SendTell,
|
||||
Action<uint, string> SendChannel,
|
||||
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
|
||||
Action<string>? Log = null);
|
||||
|
||||
/// <summary>
|
||||
/// Owns the command surface for one exact live-session generation. The router
|
||||
/// itself is the published bus, so a retained reference becomes inert before
|
||||
/// the displaced transport is disposed.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private LiveCommandBus? _commands;
|
||||
private ClientCommandController.Bindings? _clientCommands;
|
||||
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
||||
|
||||
public LiveSessionCommandRouter(LiveSessionCommandBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
ArgumentNullException.ThrowIfNull(bindings.ClientCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Chat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.TurbineChat);
|
||||
ArgumentNullException.ThrowIfNull(bindings.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTalk);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTell);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendChannel);
|
||||
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
|
||||
|
||||
_clientCommands = bindings.ClientCommands;
|
||||
var commands = new LiveCommandBus();
|
||||
var clientCommands = new ClientCommandController(
|
||||
BuildGuardedClientCommands(bindings.ClientCommands));
|
||||
commands.Register<ExecuteClientCommandCmd>(clientCommands.Execute);
|
||||
commands.Register<SendServerCommandCmd>(command =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(command.Text))
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
});
|
||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
||||
_commands = commands;
|
||||
}
|
||||
|
||||
public bool IsActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _state == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 2)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
|
||||
_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish<T>(T command) where T : notnull
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state == 1)
|
||||
_commands?.Publish(command);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
LiveCommandBus? commands;
|
||||
lock (_gate)
|
||||
{
|
||||
_state = 2;
|
||||
commands = _commands;
|
||||
_commands = null;
|
||||
_clientCommands = null;
|
||||
}
|
||||
|
||||
commands?.Clear();
|
||||
}
|
||||
|
||||
private void RouteChat(
|
||||
LiveSessionCommandBindings bindings,
|
||||
SendChatCmd command)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command.Text))
|
||||
return;
|
||||
|
||||
switch (command.Channel)
|
||||
{
|
||||
case ChatChannelKind.Say:
|
||||
// ACE echoes HearSpeech to the sender. Retail therefore uses
|
||||
// the authoritative inbound line rather than a local echo.
|
||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
return;
|
||||
|
||||
case ChatChannelKind.Tell:
|
||||
if (string.IsNullOrEmpty(command.TargetName))
|
||||
return;
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendTell(command.TargetName, command.Text)))
|
||||
return;
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Tell,
|
||||
command.Text,
|
||||
targetOrChannel: command.TargetName);
|
||||
return;
|
||||
}
|
||||
|
||||
TurbineResolution? turbine = TurbineChatRouting.Resolve(
|
||||
command.Channel,
|
||||
bindings.TurbineChat);
|
||||
if (turbine is not null)
|
||||
{
|
||||
uint cookie = bindings.TurbineChat.NextContextId();
|
||||
uint senderGuid = bindings.PlayerGuid();
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
|
||||
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
|
||||
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}");
|
||||
SendIfActive(() => bindings.SendTurbineChat(
|
||||
turbine.Value.RoomId,
|
||||
turbine.Value.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
senderGuid,
|
||||
command.Text,
|
||||
cookie));
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel);
|
||||
if (legacy is null)
|
||||
{
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: SendChatCmd kind={command.Channel} dropped " +
|
||||
$"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)");
|
||||
return;
|
||||
}
|
||||
|
||||
bindings.Log?.Invoke(
|
||||
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
|
||||
$"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}");
|
||||
if (!SendIfActive(() =>
|
||||
bindings.SendChannel(legacy.Value.ChannelId, command.Text)))
|
||||
return;
|
||||
bindings.Chat.OnSelfSent(
|
||||
ChatKind.Channel,
|
||||
command.Text,
|
||||
targetOrChannel: legacy.Value.DisplayName);
|
||||
}
|
||||
|
||||
private ClientCommandController.Bindings BuildGuardedClientCommands(
|
||||
ClientCommandController.Bindings source) => new(
|
||||
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),
|
||||
TeleportToMarketplace: () => InvokeClient(static b => b.TeleportToMarketplace()),
|
||||
TeleportToPkArena: () => InvokeClient(static b => b.TeleportToPkArena()),
|
||||
TeleportToPkLiteArena: () => InvokeClient(static b => b.TeleportToPkLiteArena()),
|
||||
TeleportToHouse: () => InvokeClient(static b => b.TeleportToHouse()),
|
||||
TeleportToMansion: () => InvokeClient(static b => b.TeleportToMansion()),
|
||||
QueryAge: () => InvokeClient(static b => b.QueryAge()),
|
||||
QueryBirth: () => InvokeClient(static b => b.QueryBirth()),
|
||||
ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()),
|
||||
ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()),
|
||||
ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)),
|
||||
ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)),
|
||||
PlayerPublicWeenieBitfield: () =>
|
||||
ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)),
|
||||
ClientVersion: () => ReadClient(static b => b.ClientVersion(), string.Empty),
|
||||
CurrentPosition: () =>
|
||||
ReadClient(static b => b.CurrentPosition(), default(AcDream.Core.Physics.Position?)),
|
||||
LastOutsideCorpsePosition: () =>
|
||||
ReadClient(static b => b.LastOutsideCorpsePosition(), default(AcDream.Core.Physics.Position?)),
|
||||
ShowConfirmation: (text, callback) =>
|
||||
InvokeClient(b => b.ShowConfirmation(text, callback)),
|
||||
Suicide: () => InvokeClient(static b => b.Suicide()),
|
||||
ClearChat: all => InvokeClient(b => b.ClearChat(all)),
|
||||
SaveUi: name => InvokeClient(b => b.SaveUi(name)),
|
||||
LoadUi: name => InvokeClient(b => b.LoadUi(name)),
|
||||
SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()),
|
||||
LoadAutoUi: () => InvokeClient(static b => b.LoadAutoUi()),
|
||||
IsAway: () => ReadClient(static b => b.IsAway(), false),
|
||||
SetAway: away => InvokeClient(b => b.SetAway(away)),
|
||||
SetAwayMessage: message => InvokeClient(b => b.SetAwayMessage(message)),
|
||||
AcceptLootPermits: () => ReadClient(static b => b.AcceptLootPermits(), false),
|
||||
SetAcceptLootPermits: accept => InvokeClient(b => b.SetAcceptLootPermits(accept)),
|
||||
DisplayConsent: () => InvokeClient(static b => b.DisplayConsent()),
|
||||
ClearConsent: () => InvokeClient(static b => b.ClearConsent()),
|
||||
RemoveConsent: name => InvokeClient(b => b.RemoveConsent(name)),
|
||||
SendEmote: text => InvokeClient(b => b.SendEmote(text)),
|
||||
Friends: source.Friends,
|
||||
AddFriend: name => InvokeClient(b => b.AddFriend(name)),
|
||||
RemoveFriend: id => InvokeClient(b => b.RemoveFriend(id)),
|
||||
ClearFriends: () => InvokeClient(static b => b.ClearFriends()),
|
||||
RequestLegacyFriends: () => InvokeClient(static b => b.RequestLegacyFriends()),
|
||||
Squelch: source.Squelch,
|
||||
ModifyCharacterSquelch: (add, id, name, chatType) =>
|
||||
InvokeClient(b => b.ModifyCharacterSquelch(add, id, name, chatType)),
|
||||
ModifyAccountSquelch: (add, name) =>
|
||||
InvokeClient(b => b.ModifyAccountSquelch(add, name)),
|
||||
ModifyGlobalSquelch: (add, chatType) =>
|
||||
InvokeClient(b => b.ModifyGlobalSquelch(add, chatType)),
|
||||
LastTeller: () => ReadClient(static b => b.LastTeller(), default(string?)),
|
||||
ClearDesiredComponents: () => InvokeClient(static b => b.ClearDesiredComponents()),
|
||||
HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false),
|
||||
FillComponentBuyList: (componentId, targetCount) =>
|
||||
InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)));
|
||||
|
||||
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ClientCommandController.Bindings? bindings = _clientCommands;
|
||||
if (_state != 1 || bindings is null)
|
||||
return false;
|
||||
invoke(bindings);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private TResult ReadClient<TResult>(
|
||||
Func<ClientCommandController.Bindings, TResult> read,
|
||||
TResult fallback)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ClientCommandController.Bindings? bindings = _clientCommands;
|
||||
return _state == 1 && bindings is not null
|
||||
? read(bindings)
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private bool SendIfActive(Action send)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_state != 1)
|
||||
return false;
|
||||
send();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct TurbineResolution(
|
||||
uint RoomId,
|
||||
uint ChatType,
|
||||
string DisplayName);
|
||||
|
||||
internal static class TurbineChatRouting
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the server-assigned Turbine room for one UI channel. This is
|
||||
/// the existing holtburger <c>resolve_turbine_channel</c> mapping
|
||||
/// (<c>references/holtburger/.../client/commands.rs</c>, lines 64-98),
|
||||
/// moved intact from GameWindow with its runtime-room gate preserved.
|
||||
/// </summary>
|
||||
public static TurbineResolution? Resolve(
|
||||
ChatChannelKind kind,
|
||||
TurbineChatState state)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
if (!state.Enabled)
|
||||
return null;
|
||||
|
||||
(uint Room, uint ChatType, string Name) = kind switch
|
||||
{
|
||||
ChatChannelKind.Allegiance =>
|
||||
(state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"),
|
||||
ChatChannelKind.General =>
|
||||
(state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"),
|
||||
ChatChannelKind.Trade =>
|
||||
(state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"),
|
||||
ChatChannelKind.Lfg =>
|
||||
(state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"),
|
||||
ChatChannelKind.Roleplay =>
|
||||
(state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"),
|
||||
ChatChannelKind.Society =>
|
||||
(state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"),
|
||||
ChatChannelKind.Olthoi =>
|
||||
(state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"),
|
||||
_ => (0u, 0u, string.Empty),
|
||||
};
|
||||
|
||||
return Room == 0u
|
||||
? null
|
||||
: new TurbineResolution(Room, ChatType, Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the retail-facing channel label from the inbound Turbine chat
|
||||
/// type, retaining the numeric-room fallback for unknown extensions.
|
||||
/// </summary>
|
||||
public static string DisplayName(uint roomId, uint chatType) =>
|
||||
(TurbineChat.ChatType)chatType switch
|
||||
{
|
||||
TurbineChat.ChatType.Allegiance => "Allegiance",
|
||||
TurbineChat.ChatType.General => "General",
|
||||
TurbineChat.ChatType.Trade => "Trade",
|
||||
TurbineChat.ChatType.Lfg => "LFG",
|
||||
TurbineChat.ChatType.Roleplay => "Roleplay",
|
||||
TurbineChat.ChatType.Society => "Society",
|
||||
TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||||
TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||||
TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||||
TurbineChat.ChatType.Olthoi => "Olthoi",
|
||||
_ => $"Room 0x{roomId:X8}",
|
||||
};
|
||||
}
|
||||
|
|
@ -1,114 +1,648 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the network-side lifecycle of a live <see cref="WorldSession"/> —
|
||||
/// DNS resolution, endpoint construction, session instantiation, per-frame
|
||||
/// <c>Tick</c>, and disposal. The post-construction work (event wiring,
|
||||
/// <c>Connect</c>, character validation, <c>EnterWorld</c>, post-login UI
|
||||
/// state setup) stays in <c>GameWindow</c> for now because it touches
|
||||
/// renderer / chat / player-controller state that hasn't been extracted yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Step 2 of the extraction sequence described in
|
||||
/// <c>docs/architecture/code-structure.md</c> §4. Future expansions can
|
||||
/// fold more of <c>TryStartLiveSession</c> into this controller as the
|
||||
/// surrounding state (event handlers, command bus, settings VM) gets
|
||||
/// extracted in later steps.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Behavior preservation contract:</strong> this class produces
|
||||
/// byte-identical console output and event-wireup sequencing to the
|
||||
/// pre-refactor inline code path. The DNS-resolution lines, the
|
||||
/// "live: connecting to ..." line, and the wiring-vs-Connect ordering
|
||||
/// all match the previous flow.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LiveSessionController : IDisposable
|
||||
internal enum LiveSessionStartStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Active session, or <see langword="null"/> when offline / before
|
||||
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
public WorldSession? Session { get; private set; }
|
||||
Disabled,
|
||||
MissingCredentials,
|
||||
NoCharacters,
|
||||
Connected,
|
||||
Deferred,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
|
||||
/// hands it to <paramref name="wireEvents"/> for caller-side event
|
||||
/// subscriptions, and returns the live session. The caller is
|
||||
/// responsible for the subsequent <c>Connect</c> /
|
||||
/// <c>EnterWorld</c> dance.
|
||||
/// </summary>
|
||||
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> wireEvents)
|
||||
internal sealed record LiveSessionCharacterSelection(
|
||||
int ActiveIndex,
|
||||
uint CharacterId,
|
||||
string CharacterName,
|
||||
string AccountName);
|
||||
|
||||
internal sealed record LiveSessionStartResult(
|
||||
LiveSessionStartStatus Status,
|
||||
LiveSessionCharacterSelection? Selection = null,
|
||||
Exception? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Narrow App composition boundary for one exact WorldSession generation.
|
||||
/// The host owns domain sinks and presentation state; the controller owns the
|
||||
/// connect/enter/stop transaction and never reaches into GameWindow state.
|
||||
/// </summary>
|
||||
internal interface ILiveSessionLifecycleHost
|
||||
{
|
||||
LiveSessionBinding BindSession(WorldSession session);
|
||||
void ResetSessionState();
|
||||
void ReportConnecting(string host, int port, string user);
|
||||
void ReportConnected();
|
||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||
void DetachSession(WorldSession session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns routing and commands for one exact session. Teardown is ordered and
|
||||
/// retryable: outbound commands become inert before inbound subscriptions are
|
||||
/// detached.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionBinding : IDisposable
|
||||
{
|
||||
private readonly Action _activateCommands;
|
||||
private readonly Action _deactivateCommands;
|
||||
private readonly Action _detachEvents;
|
||||
private bool _commandsDeactivated;
|
||||
private bool _eventsDetached;
|
||||
private bool _commandsActivated;
|
||||
|
||||
public LiveSessionBinding(
|
||||
WorldSession session,
|
||||
ICommandBus commands,
|
||||
Action activateCommands,
|
||||
Action deactivateCommands,
|
||||
Action detachEvents)
|
||||
{
|
||||
if (options is null) throw new ArgumentNullException(nameof(options));
|
||||
if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
|
||||
|
||||
if (!options.LiveMode) return null;
|
||||
|
||||
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||
{
|
||||
Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||
Session = new WorldSession(endpoint);
|
||||
wireEvents(Session);
|
||||
return Session;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"live: session setup failed: {ex.Message}");
|
||||
Session?.Dispose();
|
||||
Session = null;
|
||||
return null;
|
||||
}
|
||||
Session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
Commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_activateCommands = activateCommands ?? throw new ArgumentNullException(nameof(activateCommands));
|
||||
_deactivateCommands = deactivateCommands ?? throw new ArgumentNullException(nameof(deactivateCommands));
|
||||
_detachEvents = detachEvents ?? throw new ArgumentNullException(nameof(detachEvents));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains the inbound network queue. Proxies to
|
||||
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
|
||||
/// is <see langword="null"/>.
|
||||
/// </summary>
|
||||
public void Tick() => Session?.Tick();
|
||||
public WorldSession Session { get; }
|
||||
public ICommandBus Commands { get; }
|
||||
|
||||
public void ActivateCommands()
|
||||
{
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionBinding));
|
||||
if (_commandsActivated)
|
||||
return;
|
||||
_activateCommands();
|
||||
if (_commandsDeactivated || _eventsDetached)
|
||||
return;
|
||||
_commandsActivated = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tears down the live session. Safe to call multiple times.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Session?.Dispose();
|
||||
Session = null;
|
||||
if (!_commandsDeactivated)
|
||||
{
|
||||
_deactivateCommands();
|
||||
_commandsDeactivated = true;
|
||||
}
|
||||
if (!_eventsDetached)
|
||||
{
|
||||
_detachEvents();
|
||||
_eventsDetached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a host string (literal IP or DNS name) to an
|
||||
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
|
||||
/// try <see cref="IPAddress.TryParse"/> first, fall back to
|
||||
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
|
||||
/// IPv4 UDP exclusively), throw on empty resolution.
|
||||
/// </summary>
|
||||
private static IPEndPoint ResolveEndpoint(string host, int port)
|
||||
internal interface ILiveSessionOperations
|
||||
{
|
||||
IPEndPoint ResolveEndpoint(string host, int port);
|
||||
WorldSession CreateSession(IPEndPoint endpoint);
|
||||
void Connect(WorldSession session, string user, string password);
|
||||
CharacterList.Parsed? GetCharacters(WorldSession session);
|
||||
void EnterWorld(WorldSession session, int activeCharacterIndex);
|
||||
void Tick(WorldSession session);
|
||||
void DisposeSession(WorldSession session);
|
||||
}
|
||||
|
||||
internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public static ProductionLiveSessionOperations Instance { get; } = new();
|
||||
|
||||
private ProductionLiveSessionOperations() { }
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port)
|
||||
{
|
||||
IPAddress ip;
|
||||
if (!IPAddress.TryParse(host, out ip!))
|
||||
{
|
||||
var addrs = Dns.GetHostAddresses(host);
|
||||
ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? (addrs.Length > 0
|
||||
? addrs[0]
|
||||
: throw new Exception($"DNS resolved no addresses for '{host}'"));
|
||||
IPAddress[] addresses = Dns.GetHostAddresses(host);
|
||||
ip = Array.Find(
|
||||
addresses,
|
||||
static address => address.AddressFamily == AddressFamily.InterNetwork)
|
||||
?? (addresses.Length != 0
|
||||
? addresses[0]
|
||||
: throw new InvalidOperationException(
|
||||
$"DNS resolved no addresses for '{host}'"));
|
||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||
}
|
||||
return new IPEndPoint(ip, port);
|
||||
}
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) => new(endpoint);
|
||||
|
||||
public void Connect(WorldSession session, string user, string password) =>
|
||||
session.Connect(user, password);
|
||||
|
||||
public CharacterList.Parsed? GetCharacters(WorldSession session) => session.Characters;
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
|
||||
session.EnterWorld(activeCharacterIndex);
|
||||
|
||||
public void Tick(WorldSession session) => session.Tick();
|
||||
|
||||
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sole owner of the App lifetime for a live WorldSession: endpoint resolution,
|
||||
/// complete pre-Connect routing, character validation/entry, active command
|
||||
/// publication, exact-generation Tick, graceful replacement, and convergent
|
||||
/// teardown. Retail wire behavior remains inside WorldSession.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionController
|
||||
: IDisposable,
|
||||
ILiveSessionFramePhase,
|
||||
ILiveInWorldSource,
|
||||
ILiveWorldSessionSource
|
||||
{
|
||||
private sealed class SessionScope(
|
||||
WorldSession session,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
private int _teardownStage;
|
||||
|
||||
public WorldSession Session { get; } = session;
|
||||
public ILiveSessionLifecycleHost Host { get; } = host;
|
||||
public LiveSessionBinding? Binding { get; set; }
|
||||
public bool HostAttached { get; set; }
|
||||
|
||||
public void DrainTeardown(ILiveSessionOperations operations)
|
||||
{
|
||||
if (_teardownStage == 0)
|
||||
{
|
||||
Binding?.Dispose();
|
||||
_teardownStage = 1;
|
||||
}
|
||||
if (_teardownStage == 1)
|
||||
{
|
||||
operations.DisposeSession(Session);
|
||||
_teardownStage = 2;
|
||||
}
|
||||
if (_teardownStage == 2)
|
||||
{
|
||||
if (HostAttached)
|
||||
Host.DetachSession(Session);
|
||||
_teardownStage = 3;
|
||||
}
|
||||
if (_teardownStage == 3)
|
||||
{
|
||||
Host.ResetSessionState();
|
||||
_teardownStage = 4;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsTeardownComplete => _teardownStage == 4;
|
||||
}
|
||||
|
||||
private enum PendingKind
|
||||
{
|
||||
Stop,
|
||||
Reconnect,
|
||||
Dispose,
|
||||
}
|
||||
|
||||
private sealed record PendingOperation(
|
||||
PendingKind Kind,
|
||||
RuntimeOptions? Options = null,
|
||||
ILiveSessionLifecycleHost? Host = null);
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly ILiveSessionOperations _operations;
|
||||
private SessionScope? _scope;
|
||||
private SessionScope? _retiredScope;
|
||||
private ILiveSessionLifecycleHost? _pendingInitialResetHost;
|
||||
private PendingOperation? _pendingOperation;
|
||||
private int _operationDepth;
|
||||
private bool _inWorld;
|
||||
private bool _disposeRequested;
|
||||
private bool _disposed;
|
||||
private ulong _generation;
|
||||
private LiveSessionCharacterSelection? _activeSelection;
|
||||
|
||||
public LiveSessionController()
|
||||
: this(ProductionLiveSessionOperations.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
internal LiveSessionController(ILiveSessionOperations operations)
|
||||
{
|
||||
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
|
||||
}
|
||||
|
||||
public WorldSession? CurrentSession
|
||||
{
|
||||
get { lock (_gate) return _scope?.Session; }
|
||||
}
|
||||
|
||||
public ICommandBus Commands
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
return _inWorld && _scope?.Binding is { } binding
|
||||
? binding.Commands
|
||||
: NullCommandBus.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInWorld
|
||||
{
|
||||
get { lock (_gate) return _inWorld; }
|
||||
}
|
||||
|
||||
public ulong SessionGeneration
|
||||
{
|
||||
get { lock (_gate) return _generation; }
|
||||
}
|
||||
|
||||
internal bool IsDisposalComplete
|
||||
{
|
||||
get { lock (_gate) return _disposed; }
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Start(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (_inWorld)
|
||||
return ConnectedResult();
|
||||
return RunTopLevel(() => StartCore(options, host, resetHost: true));
|
||||
}
|
||||
}
|
||||
|
||||
internal LiveSessionStartResult Reconnect(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(host);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposing();
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Reconnect, options, host));
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
return RunTopLevel(() => ReconnectCore(options, host));
|
||||
}
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Stop));
|
||||
return;
|
||||
}
|
||||
RunTopLevel(StopCore);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
if (!_inWorld || _scope is null || _operationDepth != 0)
|
||||
return;
|
||||
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
SessionScope scope = _scope;
|
||||
ulong generation = _generation;
|
||||
try
|
||||
{
|
||||
_operations.Tick(scope.Session);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return;
|
||||
}
|
||||
catch (Exception tickError)
|
||||
{
|
||||
Exception error = StopAfterFailure(tickError);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposeRequested = true;
|
||||
if (_operationDepth != 0)
|
||||
{
|
||||
Schedule(new PendingOperation(PendingKind.Dispose));
|
||||
return;
|
||||
}
|
||||
|
||||
RunTopLevel(DisposeCore);
|
||||
}
|
||||
}
|
||||
|
||||
private LiveSessionStartResult ReconnectCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host)
|
||||
{
|
||||
ILiveSessionLifecycleHost? oldHost =
|
||||
_scope?.Host ?? _retiredScope?.Host ?? _pendingInitialResetHost;
|
||||
ulong generationBeforeStop = _generation;
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
if (_generation != unchecked(generationBeforeStop + 1))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
return StartCore(options, host, resetHost: !ReferenceEquals(oldHost, host));
|
||||
}
|
||||
|
||||
private LiveSessionStartResult StartCore(
|
||||
RuntimeOptions options,
|
||||
ILiveSessionLifecycleHost host,
|
||||
bool resetHost)
|
||||
{
|
||||
ulong generation = ++_generation;
|
||||
try
|
||||
{
|
||||
DrainRetiredScope();
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
if (resetHost)
|
||||
ResetHostBeforeStart(host, generation);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Failed, Error: error);
|
||||
}
|
||||
|
||||
if (!options.LiveMode)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Disabled);
|
||||
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.MissingCredentials);
|
||||
|
||||
SessionScope? scope = null;
|
||||
try
|
||||
{
|
||||
IPEndPoint endpoint = _operations.ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||
if (_generation != generation)
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||
WorldSession session = _operations.CreateSession(endpoint);
|
||||
scope = new SessionScope(session, host);
|
||||
_scope = scope;
|
||||
_inWorld = false;
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
LiveSessionBinding binding = host.BindSession(session);
|
||||
scope.Binding = binding;
|
||||
scope.HostAttached = true;
|
||||
if (!ReferenceEquals(binding.Session, session))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The live-session host returned a binding for a different session.");
|
||||
}
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
host.ReportConnecting(options.LiveHost, options.LivePort, options.LiveUser);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
_operations.Connect(session, options.LiveUser, options.LivePass);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
host.ReportConnected();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is null
|
||||
|| !CharacterList.TrySelectFirstAvailable(characters, out CharacterList.Selection selected))
|
||||
{
|
||||
Console.WriteLine("live: no available characters on account; disconnecting");
|
||||
StopCore();
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.NoCharacters);
|
||||
}
|
||||
|
||||
var selection = new LiveSessionCharacterSelection(
|
||||
selected.ActiveIndex,
|
||||
selected.Character.Id,
|
||||
selected.Character.Name,
|
||||
characters.AccountName);
|
||||
host.ApplySelectedCharacter(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine(
|
||||
$"live: entering world as 0x{selection.CharacterId:X8} {selection.CharacterName}");
|
||||
_operations.EnterWorld(session, selection.ActiveIndex);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
binding.ActivateCommands();
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
Console.WriteLine("live: in world — CreateObject stream active");
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Connected,
|
||||
selection);
|
||||
}
|
||||
catch (Exception startError)
|
||||
{
|
||||
return new LiveSessionStartResult(
|
||||
LiveSessionStartStatus.Failed,
|
||||
Error: StopAfterFailure(startError));
|
||||
}
|
||||
}
|
||||
|
||||
private Exception StopAfterFailure(Exception operationError)
|
||||
{
|
||||
try
|
||||
{
|
||||
StopCore();
|
||||
return operationError;
|
||||
}
|
||||
catch (Exception cleanupError)
|
||||
{
|
||||
return new AggregateException(
|
||||
"Live-session operation and cleanup both failed.",
|
||||
operationError,
|
||||
cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopCore()
|
||||
{
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_activeSelection = null;
|
||||
if (_scope is { } scope)
|
||||
{
|
||||
_scope = null;
|
||||
if (_retiredScope is not null && !ReferenceEquals(_retiredScope, scope))
|
||||
throw new InvalidOperationException(
|
||||
"A second live-session scope cannot retire before the first converges.");
|
||||
_retiredScope = scope;
|
||||
}
|
||||
DrainRetiredScope();
|
||||
DrainPendingInitialReset();
|
||||
}
|
||||
|
||||
private void DrainRetiredScope()
|
||||
{
|
||||
if (_retiredScope is not { } retired)
|
||||
return;
|
||||
retired.DrainTeardown(_operations);
|
||||
if (retired.IsTeardownComplete)
|
||||
_retiredScope = null;
|
||||
}
|
||||
|
||||
private void ResetHostBeforeStart(
|
||||
ILiveSessionLifecycleHost host,
|
||||
ulong generation)
|
||||
{
|
||||
bool requestedHostAlreadyReset = false;
|
||||
if (_pendingInitialResetHost is { } pending)
|
||||
{
|
||||
pending.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
requestedHostAlreadyReset = ReferenceEquals(pending, host);
|
||||
}
|
||||
if (requestedHostAlreadyReset || _generation != generation)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
host.ResetSessionState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_pendingInitialResetHost = host;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainPendingInitialReset()
|
||||
{
|
||||
if (_pendingInitialResetHost is not { } host)
|
||||
return;
|
||||
host.ResetSessionState();
|
||||
_pendingInitialResetHost = null;
|
||||
}
|
||||
|
||||
private void Schedule(PendingOperation operation)
|
||||
{
|
||||
if (_pendingOperation?.Kind == PendingKind.Dispose)
|
||||
return;
|
||||
_pendingOperation = operation;
|
||||
++_generation;
|
||||
_inWorld = false;
|
||||
_scope?.Binding?.Dispose();
|
||||
}
|
||||
|
||||
private T RunTopLevel<T>(Func<T> operation)
|
||||
{
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
return operation();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
if (_operationDepth == 0)
|
||||
DrainPendingOperations();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTopLevel(Action operation) =>
|
||||
RunTopLevel(() =>
|
||||
{
|
||||
operation();
|
||||
return true;
|
||||
});
|
||||
|
||||
private void DrainPendingOperations()
|
||||
{
|
||||
while (_pendingOperation is { } pending)
|
||||
{
|
||||
_pendingOperation = null;
|
||||
_operationDepth++;
|
||||
try
|
||||
{
|
||||
switch (pending.Kind)
|
||||
{
|
||||
case PendingKind.Stop:
|
||||
StopCore();
|
||||
break;
|
||||
case PendingKind.Reconnect:
|
||||
_ = ReconnectCore(pending.Options!, pending.Host!);
|
||||
break;
|
||||
case PendingKind.Dispose:
|
||||
DisposeCore();
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationDepth--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeCore()
|
||||
{
|
||||
StopCore();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private bool IsCurrent(SessionScope scope, ulong generation) =>
|
||||
ReferenceEquals(_scope, scope) && _generation == generation;
|
||||
|
||||
private LiveSessionStartResult ConnectedResult()
|
||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||
|
||||
private void ThrowIfDisposing()
|
||||
{
|
||||
if (_disposeRequested || _disposed)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionController));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
423
src/AcDream.App/Net/LiveSessionEventRouter.cs
Normal file
423
src/AcDream.App/Net/LiveSessionEventRouter.cs
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal sealed record LiveEntitySessionSink(
|
||||
Action<WorldSession.EntitySpawn> Spawned,
|
||||
Action<DeleteObject.Parsed> Deleted,
|
||||
Action<PickupEvent.Parsed> PickedUp,
|
||||
Action<WorldSession.EntityMotionUpdate> MotionUpdated,
|
||||
Action<WorldSession.EntityPositionUpdate> PositionUpdated,
|
||||
Action<VectorUpdate.Parsed> VectorUpdated,
|
||||
Action<SetState.Parsed> StateUpdated,
|
||||
Action<ParentEvent.Parsed> ParentUpdated,
|
||||
Action<uint> TeleportStarted,
|
||||
Action<ObjDescEvent.Parsed> AppearanceUpdated,
|
||||
Action<PlayPhysicsScript> PlayPhysicsScript,
|
||||
Action<PlayPhysicsScriptType> PlayPhysicsScriptType);
|
||||
|
||||
internal sealed record LiveEnvironmentSessionSink(
|
||||
Action<uint> EnvironChanged,
|
||||
Action<double> ServerTimeUpdated);
|
||||
|
||||
internal sealed record LiveInventorySessionBindings(
|
||||
ClientObjectTable Objects,
|
||||
LocalPlayerState LocalPlayer,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
|
||||
Action<uint>? OnUseDone,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers);
|
||||
|
||||
internal sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
Spellbook Spellbook,
|
||||
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
|
||||
Action<int, int>? OnSkillsUpdated,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
||||
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
|
||||
Action<uint, uint>? OnCharacterOptions,
|
||||
Func<double>? ClientTime);
|
||||
|
||||
internal sealed record LiveSocialSessionBindings(
|
||||
ChatLog Chat,
|
||||
TurbineChatState TurbineChat,
|
||||
FriendsState? Friends,
|
||||
SquelchState? Squelch);
|
||||
|
||||
/// <summary>
|
||||
/// Owns every inbound subscription for one exact live session. Domain state
|
||||
/// remains in the supplied sinks; this class owns only routing and teardown.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
||||
{
|
||||
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
||||
private readonly Action<int>? _constructionCheckpoint;
|
||||
private readonly WorldSession _session;
|
||||
private readonly LiveEntitySessionSink _entities;
|
||||
private readonly LiveEnvironmentSessionSink _environment;
|
||||
private readonly LiveInventorySessionBindings _inventory;
|
||||
private readonly LiveCharacterSessionBindings _character;
|
||||
private readonly LiveSocialSessionBindings _social;
|
||||
private int _constructionStep;
|
||||
private int _accepting;
|
||||
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
|
||||
|
||||
public LiveSessionEventRouter(
|
||||
WorldSession session,
|
||||
LiveEntitySessionSink entities,
|
||||
LiveEnvironmentSessionSink environment,
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character,
|
||||
LiveSocialSessionBindings social,
|
||||
Action<int>? constructionCheckpoint = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
Validate(entities, environment, inventory, character, social);
|
||||
_session = session;
|
||||
_entities = entities;
|
||||
_environment = environment;
|
||||
_inventory = inventory;
|
||||
_character = character;
|
||||
_social = social;
|
||||
_constructionCheckpoint = constructionCheckpoint;
|
||||
}
|
||||
|
||||
public void Attach()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
|
||||
throw new InvalidOperationException(
|
||||
"Live-session event routing can only attach once.");
|
||||
|
||||
Interlocked.Exchange(ref _accepting, 1);
|
||||
WorldSession session = _session;
|
||||
LiveEntitySessionSink entities = _entities;
|
||||
LiveEnvironmentSessionSink environment = _environment;
|
||||
LiveInventorySessionBindings inventory = _inventory;
|
||||
LiveCharacterSessionBindings character = _character;
|
||||
LiveSocialSessionBindings social = _social;
|
||||
|
||||
try
|
||||
{
|
||||
// Preserve the shipped pre-Connect registration order. Property
|
||||
// state is installed before lifecycle packets can be dispatched.
|
||||
_subscriptions.Add(ObjectTableWiring.Wire(
|
||||
session,
|
||||
inventory.Objects,
|
||||
inventory.PlayerGuid,
|
||||
inventory.LocalPlayer,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_subscriptions.Add(CombatStateWiring.Wire(
|
||||
session,
|
||||
character.Combat,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned);
|
||||
Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted);
|
||||
Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp);
|
||||
Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated);
|
||||
Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated);
|
||||
Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated);
|
||||
Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated);
|
||||
Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated);
|
||||
Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted);
|
||||
Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated);
|
||||
Subscribe(
|
||||
h => session.PlayPhysicsScriptReceived += h,
|
||||
h => session.PlayPhysicsScriptReceived -= h,
|
||||
entities.PlayPhysicsScript);
|
||||
Subscribe(
|
||||
h => session.PlayPhysicsScriptTypeReceived += h,
|
||||
h => session.PlayPhysicsScriptTypeReceived -= h,
|
||||
entities.PlayPhysicsScriptType);
|
||||
|
||||
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
|
||||
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
|
||||
|
||||
_subscriptions.Add(GameEventWiring.WireAll(
|
||||
session.GameEvents,
|
||||
inventory.Objects,
|
||||
character.Combat,
|
||||
character.Spellbook,
|
||||
social.Chat,
|
||||
inventory.LocalPlayer,
|
||||
social.TurbineChat,
|
||||
onSkillsUpdated: character.OnSkillsUpdated,
|
||||
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
|
||||
onShortcuts: inventory.OnShortcuts,
|
||||
playerGuid: inventory.PlayerGuid,
|
||||
onUseDone: inventory.OnUseDone,
|
||||
itemMana: inventory.ItemMana,
|
||||
onConfirmationRequest: character.OnConfirmationRequest,
|
||||
onConfirmationDone: character.OnConfirmationDone,
|
||||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: inventory.OnDesiredComponents,
|
||||
onCharacterOptions: character.OnCharacterOptions,
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
accepting: IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
_subscriptions.Add(new CombatChatTranslator(
|
||||
character.Combat,
|
||||
social.Chat,
|
||||
IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
Subscribe<HearSpeech.Parsed>(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech =>
|
||||
social.Chat.OnLocalSpeech(
|
||||
speech.SenderName,
|
||||
speech.Text,
|
||||
speech.SenderGuid,
|
||||
speech.IsRanged));
|
||||
Subscribe<ServerMessage.Parsed>(
|
||||
h => session.ServerMessageReceived += h,
|
||||
h => session.ServerMessageReceived -= h,
|
||||
message => social.Chat.OnSystemMessage(message.Message, message.ChatType));
|
||||
Subscribe<EmoteText.Parsed>(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote =>
|
||||
social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid));
|
||||
Subscribe<SoulEmote.Parsed>(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote =>
|
||||
social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid));
|
||||
Subscribe<PlayerKilled.Parsed>(
|
||||
h => session.PlayerKilledReceived += h,
|
||||
h => session.PlayerKilledReceived -= h,
|
||||
killed => social.Chat.OnPlayerKilled(
|
||||
killed.DeathMessage,
|
||||
killed.VictimGuid,
|
||||
killed.KillerGuid));
|
||||
Subscribe<TurbineChat.Parsed>(
|
||||
h => session.TurbineChatReceived += h,
|
||||
h => session.TurbineChatReceived -= h,
|
||||
parsed => RouteTurbineChat(social.Chat, parsed));
|
||||
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
|
||||
inventory.LocalPlayer.OnVitalUpdate(
|
||||
vital.VitalId,
|
||||
vital.Ranks,
|
||||
vital.Start,
|
||||
vital.Xp,
|
||||
vital.Current));
|
||||
Subscribe<PrivateUpdateVital.ParsedCurrent>(
|
||||
h => session.VitalCurrentUpdated += h,
|
||||
h => session.VitalCurrentUpdated -= h,
|
||||
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
|
||||
|
||||
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Accepting => IsAccepting();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _accepting, 0);
|
||||
Interlocked.Exchange(ref _lifecycleState, 3);
|
||||
_subscriptions.Dispose();
|
||||
}
|
||||
|
||||
private void Subscribe<T>(
|
||||
Action<Action<T>> attach,
|
||||
Action<Action<T>> detach,
|
||||
Action<T> sink)
|
||||
{
|
||||
Action<T> handler = value =>
|
||||
{
|
||||
if (Volatile.Read(ref _accepting) != 0)
|
||||
sink(value);
|
||||
};
|
||||
|
||||
attach(handler);
|
||||
// Add assumes cleanup ownership before it can call an external detach.
|
||||
// If the set is already closing, it retains/retries that exact edge;
|
||||
// calling detach again here would replay a successful removal.
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
||||
private void ConstructionCheckpoint() =>
|
||||
_constructionCheckpoint?.Invoke(++_constructionStep);
|
||||
|
||||
private bool IsAccepting() => Volatile.Read(ref _accepting) != 0;
|
||||
|
||||
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
|
||||
{
|
||||
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
|
||||
return;
|
||||
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
TurbineChatRouting.DisplayName(message.RoomId, message.ChatType));
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
LiveEntitySessionSink entities,
|
||||
LiveEnvironmentSessionSink environment,
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character,
|
||||
LiveSocialSessionBindings social)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entities);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
ArgumentNullException.ThrowIfNull(character);
|
||||
ArgumentNullException.ThrowIfNull(social);
|
||||
ArgumentNullException.ThrowIfNull(entities.Spawned);
|
||||
ArgumentNullException.ThrowIfNull(entities.Deleted);
|
||||
ArgumentNullException.ThrowIfNull(entities.PickedUp);
|
||||
ArgumentNullException.ThrowIfNull(entities.MotionUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.PositionUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.VectorUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.StateUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.ParentUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.TeleportStarted);
|
||||
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
|
||||
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
|
||||
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
|
||||
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
|
||||
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
|
||||
ArgumentNullException.ThrowIfNull(inventory.Objects);
|
||||
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
|
||||
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
|
||||
ArgumentNullException.ThrowIfNull(character.Combat);
|
||||
ArgumentNullException.ThrowIfNull(character.Spellbook);
|
||||
ArgumentNullException.ThrowIfNull(social.Chat);
|
||||
ArgumentNullException.ThrowIfNull(social.TurbineChat);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveSessionSubscriptionSet : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<RetryableSubscription> _subscriptions = [];
|
||||
private bool _disposeRequested;
|
||||
|
||||
public void Add(IDisposable subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
AddRetained(new RetryableSubscription(subscription.Dispose));
|
||||
}
|
||||
|
||||
public void Add(Action unsubscribe)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(unsubscribe);
|
||||
AddRetained(new RetryableSubscription(unsubscribe));
|
||||
}
|
||||
|
||||
private void AddRetained(RetryableSubscription retained)
|
||||
{
|
||||
bool disposeNow;
|
||||
lock (_gate)
|
||||
{
|
||||
disposeNow = _disposeRequested;
|
||||
_subscriptions.Add(retained);
|
||||
}
|
||||
|
||||
if (!disposeNow)
|
||||
return;
|
||||
retained.Dispose();
|
||||
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
RetryableSubscription[] subscriptions;
|
||||
lock (_gate)
|
||||
{
|
||||
_disposeRequested = true;
|
||||
subscriptions = _subscriptions.ToArray();
|
||||
}
|
||||
|
||||
List<Exception>? errors = null;
|
||||
for (int index = subscriptions.Length - 1; index >= 0; index--)
|
||||
{
|
||||
try
|
||||
{
|
||||
subscriptions[index].Dispose();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(errors ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors is not null)
|
||||
throw new AggregateException(
|
||||
"one or more live-session subscriptions failed to detach",
|
||||
errors);
|
||||
}
|
||||
|
||||
private sealed class RetryableSubscription(Action dispose) : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private Action? _dispose = dispose;
|
||||
private bool _executing;
|
||||
private int _executingThreadId;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Action? operation;
|
||||
int threadId = Environment.CurrentManagedThreadId;
|
||||
lock (_gate)
|
||||
{
|
||||
while (_executing)
|
||||
{
|
||||
if (_executingThreadId == threadId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live-session subscription cleanup cannot complete reentrantly.");
|
||||
}
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
|
||||
operation = _dispose;
|
||||
if (operation is null)
|
||||
return;
|
||||
_executing = true;
|
||||
_executingThreadId = threadId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
operation();
|
||||
}
|
||||
catch
|
||||
{
|
||||
CompleteAttempt(succeeded: false);
|
||||
throw;
|
||||
}
|
||||
|
||||
CompleteAttempt(succeeded: true);
|
||||
}
|
||||
|
||||
private void CompleteAttempt(bool succeeded)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (succeeded)
|
||||
_dispose = null;
|
||||
_executing = false;
|
||||
_executingThreadId = 0;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
242
src/AcDream.App/Net/LiveSessionHost.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
using System.Runtime.ExceptionServices;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal interface ILiveSessionEventRouting : IDisposable
|
||||
{
|
||||
void Attach();
|
||||
}
|
||||
|
||||
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
|
||||
{
|
||||
void Activate();
|
||||
}
|
||||
|
||||
internal sealed record LiveSessionRoutingFactories(
|
||||
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
|
||||
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
|
||||
|
||||
internal sealed record LiveSessionSelectionBindings(
|
||||
Action<uint> SetPlayerIdentity,
|
||||
Action<uint> SetVitalsIdentity,
|
||||
Action<uint> SetChatIdentity,
|
||||
Action<uint> MarkPersistent,
|
||||
Action<uint> SetVanishProbeIdentity,
|
||||
Action ClearCombat);
|
||||
|
||||
internal sealed record LiveSessionEnteredWorldBindings(
|
||||
Action<string> SetActiveCharacter,
|
||||
Action RestoreLayout,
|
||||
Action SyncToolbar,
|
||||
Action<string> LoadCharacterSettings,
|
||||
Action ArmPlayerModeAutoEntry);
|
||||
|
||||
internal sealed record LiveSessionHostBindings(
|
||||
LiveSessionRoutingFactories Routing,
|
||||
LiveSessionResetBindings Reset,
|
||||
LiveSessionSelectionBindings Selection,
|
||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected);
|
||||
|
||||
/// <summary>
|
||||
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
|
||||
/// It owns callback ordering and per-generation route factories, but never
|
||||
/// mirrors session, generation, identity, routing, or command state.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionHost
|
||||
{
|
||||
private sealed class PendingRouteRollback(
|
||||
ILiveSessionCommandRouting? commands,
|
||||
ILiveSessionEventRouting? events)
|
||||
{
|
||||
private ILiveSessionCommandRouting? _commands = commands;
|
||||
private ILiveSessionEventRouting? _events = events;
|
||||
|
||||
public bool IsComplete => _commands is null && _events is null;
|
||||
|
||||
public void Drain()
|
||||
{
|
||||
List<Exception>? failures = null;
|
||||
TryDispose(ref _commands, ref failures);
|
||||
TryDispose(ref _events, ref failures);
|
||||
if (failures is not null)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Live-session route rollback did not converge.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDispose<TOwner>(
|
||||
ref TOwner? owner,
|
||||
ref List<Exception>? failures)
|
||||
where TOwner : class, IDisposable
|
||||
{
|
||||
if (owner is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
owner.Dispose();
|
||||
owner = null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly LiveSessionController _controller;
|
||||
private readonly LiveSessionRoutingFactories _routing;
|
||||
private readonly LiveSessionSelectionBindings _selection;
|
||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||
private readonly LiveSessionResetPlan _resetPlan;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
||||
public LiveSessionHost(
|
||||
LiveSessionController controller,
|
||||
LiveSessionHostBindings bindings)
|
||||
{
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
|
||||
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||
_enteredWorld = bindings.EnteredWorld
|
||||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
Validate(_selection, _enteredWorld);
|
||||
|
||||
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
|
||||
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
|
||||
Bind: BindSession,
|
||||
Reset: ResetSessionState,
|
||||
Connecting: bindings.Connecting,
|
||||
Connected: bindings.Connected,
|
||||
Selected: ApplySelection,
|
||||
Entered: ApplyEnteredWorld));
|
||||
}
|
||||
|
||||
public WorldSession? CurrentSession => _controller.CurrentSession;
|
||||
public ICommandBus Commands => _controller.Commands;
|
||||
public bool IsInWorld => _controller.IsInWorld;
|
||||
|
||||
public LiveSessionStartResult Start(RuntimeOptions options) =>
|
||||
_controller.Start(options, _lifecycle);
|
||||
|
||||
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
|
||||
_controller.Reconnect(options, _lifecycle);
|
||||
|
||||
private LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
|
||||
ILiveSessionEventRouting? events = null;
|
||||
ILiveSessionCommandRouting? commands = null;
|
||||
try
|
||||
{
|
||||
events = _routing.CreateEvents(session)
|
||||
?? throw new InvalidOperationException(
|
||||
"The live-session event factory returned null.");
|
||||
events.Attach();
|
||||
commands = _routing.CreateCommands(session)
|
||||
?? throw new InvalidOperationException(
|
||||
"The live-session command factory returned null.");
|
||||
return new LiveSessionBinding(
|
||||
session,
|
||||
commands,
|
||||
activateCommands: commands.Activate,
|
||||
deactivateCommands: commands.Dispose,
|
||||
detachEvents: events.Dispose);
|
||||
}
|
||||
catch (Exception creationError)
|
||||
{
|
||||
RethrowWithRetryableRollback(creationError, commands, events);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetSessionState()
|
||||
{
|
||||
// An incompletely detached route can still deliver callbacks into the
|
||||
// state below. Treat physical route convergence as the same hard
|
||||
// barrier used by normal LiveSessionBinding teardown.
|
||||
DrainPendingRouteRollback();
|
||||
_resetPlan.Execute();
|
||||
}
|
||||
|
||||
private void ApplySelection(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
uint id = selection.CharacterId;
|
||||
_selection.SetPlayerIdentity(id);
|
||||
_selection.SetVitalsIdentity(id);
|
||||
_selection.SetChatIdentity(id);
|
||||
_selection.MarkPersistent(id);
|
||||
_selection.SetVanishProbeIdentity(id);
|
||||
_selection.ClearCombat();
|
||||
}
|
||||
|
||||
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
string name = selection.CharacterName;
|
||||
_enteredWorld.SetActiveCharacter(name);
|
||||
_enteredWorld.RestoreLayout();
|
||||
_enteredWorld.SyncToolbar();
|
||||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
Exception creationError,
|
||||
ILiveSessionCommandRouting? commands,
|
||||
ILiveSessionEventRouting? events)
|
||||
{
|
||||
_pendingRouteRollback = new PendingRouteRollback(commands, events);
|
||||
try
|
||||
{
|
||||
DrainPendingRouteRollback();
|
||||
}
|
||||
catch (AggregateException cleanupError)
|
||||
{
|
||||
var failures = new List<Exception> { creationError };
|
||||
failures.AddRange(cleanupError.InnerExceptions);
|
||||
throw new AggregateException(
|
||||
"Live-session route construction and rollback both failed.",
|
||||
failures);
|
||||
}
|
||||
|
||||
ExceptionDispatchInfo.Capture(creationError).Throw();
|
||||
}
|
||||
|
||||
private void DrainPendingRouteRollback()
|
||||
{
|
||||
if (_pendingRouteRollback is not { } rollback)
|
||||
return;
|
||||
rollback.Drain();
|
||||
if (rollback.IsComplete)
|
||||
_pendingRouteRollback = null;
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
LiveSessionSelectionBindings selection,
|
||||
LiveSessionEnteredWorldBindings entered)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
|
||||
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
|
||||
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
|
||||
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
|
||||
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
|
||||
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
|
||||
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
|
||||
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
|
||||
}
|
||||
}
|
||||
65
src/AcDream.App/Net/LiveSessionLifecycleHost.cs
Normal file
65
src/AcDream.App/Net/LiveSessionLifecycleHost.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using AcDream.Core.Net;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
internal sealed record LiveSessionLifecycleBindings(
|
||||
Func<WorldSession, LiveSessionBinding> Bind,
|
||||
Action Reset,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected,
|
||||
Action<LiveSessionCharacterSelection> Selected,
|
||||
Action<LiveSessionCharacterSelection> Entered);
|
||||
|
||||
/// <summary>
|
||||
/// Focused adapter between the session lifetime owner and App composition.
|
||||
/// It tracks only the exact borrowed session attached to the host; domain and
|
||||
/// presentation state remain behind the supplied lifecycle callbacks.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
||||
{
|
||||
private readonly LiveSessionLifecycleBindings _bindings;
|
||||
private WorldSession? _boundSession;
|
||||
|
||||
public LiveSessionLifecycleHost(LiveSessionLifecycleBindings bindings)
|
||||
{
|
||||
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
|
||||
ArgumentNullException.ThrowIfNull(bindings.Bind);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
||||
}
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
if (_boundSession is not null)
|
||||
throw new InvalidOperationException("A live session is already attached to this host.");
|
||||
|
||||
LiveSessionBinding binding = _bindings.Bind(session);
|
||||
_boundSession = session;
|
||||
return binding;
|
||||
}
|
||||
|
||||
public void ResetSessionState() => _bindings.Reset();
|
||||
|
||||
public void ReportConnecting(string host, int port, string user) =>
|
||||
_bindings.Connecting(host, port, user);
|
||||
|
||||
public void ReportConnected() => _bindings.Connected();
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Selected(selection);
|
||||
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Entered(selection);
|
||||
|
||||
public void DetachSession(WorldSession session)
|
||||
{
|
||||
if (!ReferenceEquals(_boundSession, session))
|
||||
throw new InvalidOperationException(
|
||||
"The live-session controller attempted to detach a session that is not bound.");
|
||||
_boundSession = null;
|
||||
}
|
||||
}
|
||||
125
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal file
125
src/AcDream.App/Net/LiveSessionResetManifest.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
namespace AcDream.App.Net;
|
||||
|
||||
using AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Focused owner operations composed by the App host for one character-session
|
||||
/// reset. Mixed world/static registries are deliberately absent: their
|
||||
/// session-owned entries leave through canonical live-entity teardown.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionResetBindings
|
||||
{
|
||||
public required Action MouseCapture { get; init; }
|
||||
public required Action PlayerPresentation { get; init; }
|
||||
public required Action TeleportTransit { get; init; }
|
||||
public required Action SessionDialogs { get; init; }
|
||||
public required Action ChatCommandTargets { get; init; }
|
||||
public required Action SettingsCharacterContext { get; init; }
|
||||
public required Action EquippedChildren { get; init; }
|
||||
public required Action ExternalContainer { get; init; }
|
||||
public required Action InteractionAndSelection { get; init; }
|
||||
public required Action SelectionPresentation { get; init; }
|
||||
public required Action ObjectTable { get; init; }
|
||||
public required Action Spellbook { get; init; }
|
||||
public required Action MagicRuntime { get; init; }
|
||||
public required Action CombatAttack { get; init; }
|
||||
public required Action CombatState { get; init; }
|
||||
public required Action ItemMana { get; init; }
|
||||
public required Action LocalPlayer { get; init; }
|
||||
public required Action Friends { get; init; }
|
||||
public required Action Squelch { get; init; }
|
||||
public required Action TurbineChat { get; init; }
|
||||
public required Action ParticleVisibility { get; init; }
|
||||
public required Action InboundEventFifo { get; init; }
|
||||
public required Action LiveLiveness { get; init; }
|
||||
public required Action LiveRuntime { get; init; }
|
||||
public required Action SessionIdentity { get; init; }
|
||||
public required Action RemoteTeleport { get; init; }
|
||||
public required Action NetworkEffects { get; init; }
|
||||
public required Action AnimationHookFrames { get; init; }
|
||||
public required Action LivePresentation { get; init; }
|
||||
public required Action RemoteMovementDiagnostics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The concrete, ordered character-session reset manifest. Retail
|
||||
/// <c>CPlayerSystem::OnEndCharacterSession @ 0x00562870</c> calls End then
|
||||
/// Begin; <c>ClientUISystem::OnEndCharacterSession @ 0x00564AD0</c>
|
||||
/// independently resets gameplay UI. App resource dependencies impose the
|
||||
/// exact projection-before-canonical-runtime order pinned here.
|
||||
/// </summary>
|
||||
internal static class LiveSessionResetManifest
|
||||
{
|
||||
public static LiveSessionResetPlan Create(LiveSessionResetBindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
return new LiveSessionResetPlan(
|
||||
[
|
||||
new("mouse capture", bindings.MouseCapture),
|
||||
new("player presentation", bindings.PlayerPresentation),
|
||||
new("teleport transit", bindings.TeleportTransit),
|
||||
new("session dialogs", bindings.SessionDialogs),
|
||||
new("chat command targets", bindings.ChatCommandTargets),
|
||||
new("settings character context", bindings.SettingsCharacterContext),
|
||||
// Attachment projections own GL-backed registrations and must leave
|
||||
// before canonical live GUID records are released.
|
||||
new("equipped children", bindings.EquippedChildren),
|
||||
new("external container", bindings.ExternalContainer),
|
||||
new("interaction and selection", bindings.InteractionAndSelection),
|
||||
new("selection presentation", bindings.SelectionPresentation),
|
||||
new("object table", bindings.ObjectTable),
|
||||
new("spellbook", bindings.Spellbook),
|
||||
new("magic runtime", bindings.MagicRuntime),
|
||||
new("combat attack", bindings.CombatAttack),
|
||||
new("combat state", bindings.CombatState),
|
||||
new("item mana", bindings.ItemMana),
|
||||
new("local player", bindings.LocalPlayer),
|
||||
new("friends", bindings.Friends),
|
||||
new("squelch", bindings.Squelch),
|
||||
new("turbine chat", bindings.TurbineChat),
|
||||
new("particle visibility", bindings.ParticleVisibility),
|
||||
new("inbound event fifo", bindings.InboundEventFifo),
|
||||
new("live liveness", bindings.LiveLiveness),
|
||||
new("live runtime", bindings.LiveRuntime),
|
||||
// Identity must remain A until live teardown has converged so
|
||||
// player-specific teardown can still classify the old record.
|
||||
new("session identity", bindings.SessionIdentity),
|
||||
new("remote teleport", bindings.RemoteTeleport),
|
||||
// F754/F755 can precede CreateObject, so pending network effects
|
||||
// must clear even when no LiveEntityRecord was constructed.
|
||||
new("network effects", bindings.NetworkEffects),
|
||||
new("animation hook frames", bindings.AnimationHookFrames),
|
||||
new("live presentation", bindings.LivePresentation),
|
||||
new("remote movement diagnostics", bindings.RemoteMovementDiagnostics),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shared convergence gate for canonical live runtime teardown.</summary>
|
||||
internal static class LiveSessionEntityRuntimeReset
|
||||
{
|
||||
public static void ClearAndRequireConvergence(LiveEntityRuntime? runtime)
|
||||
{
|
||||
if (runtime is null)
|
||||
return;
|
||||
runtime.Clear();
|
||||
RequireConvergence(runtime);
|
||||
}
|
||||
|
||||
public static void RequireConvergence(LiveEntityRuntime? runtime)
|
||||
{
|
||||
if (runtime is null)
|
||||
return;
|
||||
if (runtime.Count == 0
|
||||
&& runtime.PendingTeardownCount == 0
|
||||
&& runtime.MaterializedCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"The live-entity runtime has not converged after session clear " +
|
||||
$"(records={runtime.Count}, pending={runtime.PendingTeardownCount}, " +
|
||||
$"materialized={runtime.MaterializedCount}).");
|
||||
}
|
||||
}
|
||||
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal file
79
src/AcDream.App/Net/LiveSessionResetPlan.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>One named owner operation in the live-session reset transaction.</summary>
|
||||
internal sealed record LiveSessionResetStage(string Name, Action Reset);
|
||||
|
||||
/// <summary>
|
||||
/// Executes every session-state reset stage even when an earlier owner fails.
|
||||
/// A failed attempt throws after the complete manifest has run; lifecycle code
|
||||
/// therefore cannot construct the next session until a later attempt converges.
|
||||
/// </summary>
|
||||
internal sealed class LiveSessionResetPlan
|
||||
{
|
||||
private readonly LiveSessionResetStage[] _stages;
|
||||
private int _executing;
|
||||
|
||||
public LiveSessionResetPlan(IEnumerable<LiveSessionResetStage> stages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stages);
|
||||
_stages = stages.ToArray();
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (LiveSessionResetStage stage in _stages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stage);
|
||||
if (string.IsNullOrWhiteSpace(stage.Name))
|
||||
throw new ArgumentException("Reset stage names must be non-empty.", nameof(stages));
|
||||
ArgumentNullException.ThrowIfNull(stage.Reset);
|
||||
if (!names.Add(stage.Name))
|
||||
throw new ArgumentException(
|
||||
$"Duplicate live-session reset stage '{stage.Name}'.",
|
||||
nameof(stages));
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> StageNames =>
|
||||
Array.ConvertAll(_stages, static stage => stage.Name);
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _executing, 1) != 0)
|
||||
throw new InvalidOperationException(
|
||||
"Live-session reset cannot run concurrently or reentrantly.");
|
||||
|
||||
List<Exception>? failures = null;
|
||||
try
|
||||
{
|
||||
foreach (LiveSessionResetStage stage in _stages)
|
||||
{
|
||||
try
|
||||
{
|
||||
stage.Reset();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
(failures ??= []).Add(new LiveSessionResetStageException(
|
||||
stage.Name,
|
||||
error));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _executing, 0);
|
||||
}
|
||||
|
||||
if (failures is not null)
|
||||
throw new AggregateException(
|
||||
"Live-session state did not converge; a new session must not start.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveSessionResetStageException(
|
||||
string stageName,
|
||||
Exception innerException) : Exception(
|
||||
$"Live-session reset stage '{stageName}' failed.",
|
||||
innerException)
|
||||
{
|
||||
public string StageName { get; } = stageName;
|
||||
}
|
||||
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
72
src/AcDream.App/Net/RetailSkillFormula.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Exact unsigned formula used by retail <c>SkillFormula::Calculate @
|
||||
/// 0x00591960</c>. DAT reader fields are signed storage views, so their bits
|
||||
/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words.
|
||||
/// </summary>
|
||||
internal static class RetailSkillFormula
|
||||
{
|
||||
public static bool TryCalculate(
|
||||
SkillFormula formula,
|
||||
uint attribute1,
|
||||
uint attribute2,
|
||||
out uint result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formula);
|
||||
|
||||
uint divisor = unchecked((uint)formula.Divisor);
|
||||
if (divisor == 0u)
|
||||
{
|
||||
result = 0u;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint x = unchecked((uint)formula.Attribute1Multiplier);
|
||||
uint y = unchecked((uint)formula.Attribute2Multiplier);
|
||||
uint w = unchecked((uint)formula.AdditiveBonus);
|
||||
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
|
||||
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Named live-session resolver for the attribute contribution to one skill.
|
||||
/// Table lookup and missing-property policy stay separate from retail math.
|
||||
/// </summary>
|
||||
internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
|
||||
{
|
||||
public uint Resolve(
|
||||
uint skillId,
|
||||
IReadOnlyDictionary<uint, uint> attributeCurrents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attributeCurrents);
|
||||
|
||||
if (skillTable?.Skills is null
|
||||
|| !skillTable.Skills.TryGetValue(
|
||||
(DatReaderWriter.Enums.SkillId)skillId,
|
||||
out var skillBase))
|
||||
{
|
||||
return 0u;
|
||||
}
|
||||
|
||||
SkillFormula formula = skillBase.Formula;
|
||||
attributeCurrents.TryGetValue(
|
||||
(uint)formula.Attribute1,
|
||||
out uint attribute1);
|
||||
attributeCurrents.TryGetValue(
|
||||
(uint)formula.Attribute2,
|
||||
out uint attribute2);
|
||||
return RetailSkillFormula.TryCalculate(
|
||||
formula,
|
||||
attribute1,
|
||||
attribute2,
|
||||
out uint result)
|
||||
? result
|
||||
: 0u;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow reverse seam for collaborators constructed before the live network
|
||||
/// owner. It carries no state or identity: every call resolves the one bound
|
||||
/// update-thread owner, and use before bind fails immediately.
|
||||
/// </summary>
|
||||
internal interface ILiveEntityMotionRuntimeBindings
|
||||
{
|
||||
(float Radius, float Height) GetSetupCylinder(uint serverGuid, WorldEntity entity);
|
||||
bool RouteServerMoveTo(
|
||||
MovementManager movement,
|
||||
uint cellId,
|
||||
WorldSession.EntityMotionUpdate update);
|
||||
void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid);
|
||||
void ClearTargetForHiddenEntity(uint serverGuid);
|
||||
IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid);
|
||||
}
|
||||
|
||||
internal sealed class DeferredLiveEntityMotionRuntimeBindings
|
||||
: ILiveEntityMotionRuntimeBindings
|
||||
{
|
||||
private ILiveEntityMotionRuntimeBindings? _target;
|
||||
|
||||
public void Bind(ILiveEntityMotionRuntimeBindings target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (_target is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live entity motion runtime bindings are already bound.");
|
||||
}
|
||||
_target = target;
|
||||
}
|
||||
|
||||
private ILiveEntityMotionRuntimeBindings Target =>
|
||||
_target ?? throw new InvalidOperationException(
|
||||
"Live entity motion runtime bindings were used before binding.");
|
||||
|
||||
public (float Radius, float Height) GetSetupCylinder(
|
||||
uint serverGuid,
|
||||
WorldEntity entity) => Target.GetSetupCylinder(serverGuid, entity);
|
||||
|
||||
public bool RouteServerMoveTo(
|
||||
MovementManager movement,
|
||||
uint cellId,
|
||||
WorldSession.EntityMotionUpdate update) =>
|
||||
Target.RouteServerMoveTo(movement, cellId, update);
|
||||
|
||||
public void StickToObjectFromWire(IPhysicsObjHost? host, uint targetGuid) =>
|
||||
Target.StickToObjectFromWire(host, targetGuid);
|
||||
|
||||
public void ClearTargetForHiddenEntity(uint serverGuid) =>
|
||||
Target.ClearTargetForHiddenEntity(serverGuid);
|
||||
|
||||
public IPhysicsObjHost? ResolvePhysicsHost(uint serverGuid) =>
|
||||
Target.ResolvePhysicsHost(serverGuid);
|
||||
}
|
||||
344
src/AcDream.App/Physics/EntityPhysicsHost.cs
Normal file
344
src/AcDream.App/Physics/EntityPhysicsHost.cs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and stored on the exact <c>LiveEntityRecord</c>, so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
///
|
||||
/// <para>Owns a <see cref="TargetManager"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
|
||||
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
|
||||
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
|
||||
/// target seams are repointed here, replacing the AP-79 poll adapter. The
|
||||
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
///
|
||||
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
|
||||
/// behaviorally identical because the empty facade no-ops until its first
|
||||
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
|
||||
/// deliveries to the injected MoveToManager fan FIRST, then the
|
||||
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
|
||||
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
|
||||
/// @0x00512c1a).</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private Func<Position> _getPosition;
|
||||
private Func<Vector3> _getVelocity;
|
||||
private Func<float> _getRadius;
|
||||
private Func<bool> _inContact;
|
||||
private Func<float?> _minterpMaxSpeed;
|
||||
private Func<double> _curTime;
|
||||
private Func<double> _physicsTimerTime;
|
||||
private Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private Action<TargetInfo> _handleUpdateTarget;
|
||||
private Action _interruptCurrentMovement;
|
||||
private readonly TargetManager _targetManager;
|
||||
|
||||
public EntityPhysicsHost(
|
||||
uint id,
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> handleUpdateTarget,
|
||||
Action interruptCurrentMovement)
|
||||
{
|
||||
Id = id;
|
||||
_getPosition = null!;
|
||||
_getVelocity = null!;
|
||||
_getRadius = null!;
|
||||
_inContact = null!;
|
||||
_minterpMaxSpeed = null!;
|
||||
_curTime = null!;
|
||||
_physicsTimerTime = null!;
|
||||
_getObjectA = null!;
|
||||
_handleUpdateTarget = null!;
|
||||
_interruptCurrentMovement = null!;
|
||||
Rebind(
|
||||
getPosition,
|
||||
getVelocity,
|
||||
getRadius,
|
||||
inContact,
|
||||
minterpMaxSpeed,
|
||||
curTime,
|
||||
physicsTimerTime,
|
||||
getObjectA,
|
||||
handleUpdateTarget,
|
||||
interruptCurrentMovement);
|
||||
_targetManager = new TargetManager(this);
|
||||
PositionManager = new PositionManager(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebinds the changing CPhysicsObj access seams without replacing this
|
||||
/// object's retail-lifetime managers. A static/minimal live object can gain
|
||||
/// a MovementManager later, and the local player controller can be rebuilt,
|
||||
/// but retail keeps the same CPhysicsObj, TargetManager, and PositionManager
|
||||
/// for the accepted INSTANCE_TS incarnation. Existing voyeur, sticky, and
|
||||
/// constraint state therefore survives the upgrade.
|
||||
/// </summary>
|
||||
private void Rebind(
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> handleUpdateTarget,
|
||||
Action interruptCurrentMovement)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(getPosition);
|
||||
ArgumentNullException.ThrowIfNull(getVelocity);
|
||||
ArgumentNullException.ThrowIfNull(getRadius);
|
||||
ArgumentNullException.ThrowIfNull(inContact);
|
||||
ArgumentNullException.ThrowIfNull(minterpMaxSpeed);
|
||||
ArgumentNullException.ThrowIfNull(curTime);
|
||||
ArgumentNullException.ThrowIfNull(physicsTimerTime);
|
||||
ArgumentNullException.ThrowIfNull(getObjectA);
|
||||
ArgumentNullException.ThrowIfNull(handleUpdateTarget);
|
||||
ArgumentNullException.ThrowIfNull(interruptCurrentMovement);
|
||||
|
||||
_getPosition = getPosition;
|
||||
_getVelocity = getVelocity;
|
||||
_getRadius = getRadius;
|
||||
_inContact = inContact;
|
||||
_minterpMaxSpeed = minterpMaxSpeed;
|
||||
_curTime = curTime;
|
||||
_physicsTimerTime = physicsTimerTime;
|
||||
_getObjectA = getObjectA;
|
||||
_handleUpdateTarget = handleUpdateTarget;
|
||||
_interruptCurrentMovement = interruptCurrentMovement;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a freshly composed delegate configuration while retaining this
|
||||
/// host's exact identity and manager instances.
|
||||
/// </summary>
|
||||
private void RebindFrom(EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (configuration.Id != Id)
|
||||
throw new ArgumentException(
|
||||
"A physics-host configuration must match the existing host GUID.",
|
||||
nameof(configuration));
|
||||
|
||||
Rebind(
|
||||
configuration._getPosition,
|
||||
configuration._getVelocity,
|
||||
configuration._getRadius,
|
||||
configuration._inContact,
|
||||
configuration._minterpMaxSpeed,
|
||||
configuration._curTime,
|
||||
configuration._physicsTimerTime,
|
||||
configuration._getObjectA,
|
||||
configuration._handleUpdateTarget,
|
||||
configuration._interruptCurrentMovement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the first host for an exact live-object incarnation, or
|
||||
/// enriches that incarnation's existing minimal host in place. This is the
|
||||
/// shared remote/player composition seam; it deliberately never replaces
|
||||
/// TargetManager or PositionManager ownership.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost InstallOrRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host composition.");
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is null)
|
||||
{
|
||||
runtime.InstallPhysicsHost(current, configuration);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
if (current.PhysicsHost is not EntityPhysicsHost existing)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation.");
|
||||
}
|
||||
|
||||
existing.RebindFrom(configuration);
|
||||
return existing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an exact incarnation and returns the manager-stable host that
|
||||
/// a later <see cref="InstallOrRebind"/> will publish, without changing any
|
||||
/// live delegates. Player-mode construction uses this preparation edge so
|
||||
/// DAT/physics/camera failures cannot expose a half-rebound host.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost SelectStableHostWithoutRebind(
|
||||
LiveEntityRuntime runtime,
|
||||
LiveEntityRecord expectedRecord,
|
||||
EntityPhysicsHost configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentNullException.ThrowIfNull(expectedRecord);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
if (!runtime.TryGetRecord(expectedRecord.ServerGuid, out LiveEntityRecord current)
|
||||
|| !ReferenceEquals(current, expectedRecord))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} changed incarnation during physics-host preparation.");
|
||||
}
|
||||
|
||||
return current.PhysicsHost switch
|
||||
{
|
||||
null => configuration,
|
||||
EntityPhysicsHost existing => existing,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Live entity 0x{expectedRecord.ServerGuid:X8} owns an incompatible physics-host implementation."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the position-only CPhysicsObj facade used before an animated or
|
||||
/// player movement owner exists. The delegates capture the exact live
|
||||
/// record rather than the visible-world projection, so exit_world
|
||||
/// notifications retain the object's last cell and frame after the picker
|
||||
/// view has already withdrawn it.
|
||||
/// </summary>
|
||||
internal static EntityPhysicsHost CreateMinimal(
|
||||
LiveEntityRecord record,
|
||||
Func<uint, IPhysicsObjHost?> resolve,
|
||||
Func<double> now)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
ArgumentNullException.ThrowIfNull(resolve);
|
||||
ArgumentNullException.ThrowIfNull(now);
|
||||
return new EntityPhysicsHost(
|
||||
record.ServerGuid,
|
||||
getPosition: () => new Position(
|
||||
record.FullCellId,
|
||||
record.WorldEntity?.Position ?? Vector3.Zero,
|
||||
record.WorldEntity?.Rotation ?? Quaternion.Identity),
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getRadius: () => 0f,
|
||||
inContact: () => true,
|
||||
minterpMaxSpeed: () => null,
|
||||
curTime: now,
|
||||
physicsTimerTime: now,
|
||||
getObjectA: resolve,
|
||||
handleUpdateTarget: _ => { },
|
||||
interruptCurrentMovement: () => { });
|
||||
}
|
||||
|
||||
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
|
||||
public uint Id { get; }
|
||||
public Position Position => _getPosition();
|
||||
public Vector3 Velocity => _getVelocity();
|
||||
public float Radius => _getRadius();
|
||||
public bool InContact => _inContact();
|
||||
public float? MinterpMaxSpeed => _minterpMaxSpeed();
|
||||
public double CurTime => _curTime();
|
||||
public double PhysicsTimerTime => _physicsTimerTime();
|
||||
|
||||
/// <summary>The owned voyeur manager (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>).</summary>
|
||||
public TargetManager TargetManager => _targetManager;
|
||||
|
||||
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
|
||||
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
|
||||
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
||||
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
||||
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
|
||||
public PositionManager PositionManager { get; }
|
||||
|
||||
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
|
||||
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
|
||||
public IPhysicsObjHost? GetRelationshipTarget(uint objectId) =>
|
||||
_targetManager.GetRelationshipTarget(objectId);
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
|
||||
// MovementManager (the injected MoveToManager fan) first, then
|
||||
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
|
||||
_handleUpdateTarget(info);
|
||||
PositionManager.HandleUpdateTarget(info);
|
||||
}
|
||||
public void InterruptCurrentMovement() => _interruptCurrentMovement();
|
||||
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
|
||||
|
||||
public void ClearTarget() => _targetManager.ClearTarget();
|
||||
public void ReceiveTargetUpdate(TargetInfo info, IPhysicsObjHost sender) =>
|
||||
_targetManager.ReceiveUpdate(info, sender);
|
||||
public void AddVoyeur(IPhysicsObjHost watcher, float radius, double quantum)
|
||||
=> _targetManager.AddVoyeur(watcher, radius, quantum);
|
||||
public void RemoveVoyeur(uint watcherId, IPhysicsObjHost expectedWatcher) =>
|
||||
_targetManager.RemoveVoyeur(watcherId, expectedWatcher);
|
||||
|
||||
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
|
||||
/// managers' <c>UseTime</c>.</summary>
|
||||
public void HandleTargetting() => _targetManager.HandleTargetting();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
|
||||
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
|
||||
/// watcher of this entity that it left the world (they drop the
|
||||
/// stick/moveto). Called on despawn before the host is removed from the
|
||||
/// registry.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>
|
||||
/// A Hidden object remains logically alive but is removed from the cell
|
||||
/// lookup/interaction surface. Retail's DetectionManager sends
|
||||
/// <c>LeftDetection</c> from <c>CObjCell::hide_object</c>; that manager is
|
||||
/// not ported yet, so the App bridges the same unavailability edge through
|
||||
/// the existing target fan-out. The non-Ok update tears down watcher
|
||||
/// MoveTo/Sticky consumers, and the watched-role table is cleared so an
|
||||
/// UnHide re-subscription receives a fresh initial snapshot. The object's
|
||||
/// own watcher role is deliberately preserved.
|
||||
/// </summary>
|
||||
public void NotifyHidden() =>
|
||||
_targetManager.NotifyVoyeurOfEventAndClear(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
|
||||
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
|
||||
/// non-Ok teardown path). Called after a teleport placement.</summary>
|
||||
public void NotifyTeleported()
|
||||
{
|
||||
_targetManager.ClearTarget();
|
||||
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
}
|
||||
}
|
||||
6
src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs
Normal file
6
src/AcDream.App/Physics/ILocalPlayerLandblockSource.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace AcDream.App.Physics;
|
||||
|
||||
internal interface ILocalPlayerLandblockSource
|
||||
{
|
||||
uint? LastKnownLandblockId { get; }
|
||||
}
|
||||
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
231
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable input to the canonical shadow registry for one live object.
|
||||
/// Building is DAT/physics-only and therefore testable without a window or GL
|
||||
/// context; registration remains one explicit commit.
|
||||
/// </summary>
|
||||
internal sealed record LiveEntityCollisionRegistration(
|
||||
uint EntityId,
|
||||
uint SourceId,
|
||||
Vector3 EntityWorldPosition,
|
||||
Quaternion EntityWorldRotation,
|
||||
IReadOnlyList<ShadowShape> Shapes,
|
||||
uint State,
|
||||
EntityCollisionFlags Flags,
|
||||
float WorldOffsetX,
|
||||
float WorldOffsetY,
|
||||
uint LandblockId,
|
||||
uint SeedCellId);
|
||||
|
||||
/// <summary>
|
||||
/// Ports the live-object collision-shape policy used by
|
||||
/// <c>CPartArray::FindObjCollisions</c>: CylSpheres before Spheres, every
|
||||
/// physics-BSP part, and the established Setup-radius fallback for the
|
||||
/// remaining ACE prop data.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityCollisionBuilder
|
||||
{
|
||||
private readonly Func<uint, bool> _hasPhysicsBsp;
|
||||
private readonly Func<uint, float?> _physicsBspRadius;
|
||||
private readonly LiveEntityDefaultPoseResolver _defaultPose;
|
||||
|
||||
public LiveEntityCollisionBuilder(
|
||||
PhysicsDataCache physicsData,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
: this(
|
||||
id => physicsData.GetGfxObj(id)?.BSP?.Root is not null,
|
||||
id => physicsData.GetGfxObj(id)?.BoundingSphere?.Radius,
|
||||
defaultPose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(physicsData);
|
||||
}
|
||||
|
||||
internal LiveEntityCollisionBuilder(
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
Func<uint, float?> physicsBspRadius,
|
||||
LiveEntityDefaultPoseResolver defaultPose)
|
||||
{
|
||||
_hasPhysicsBsp = hasPhysicsBsp
|
||||
?? throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||
_physicsBspRadius = physicsBspRadius
|
||||
?? throw new ArgumentNullException(nameof(physicsBspRadius));
|
||||
_defaultPose = defaultPose
|
||||
?? throw new ArgumentNullException(nameof(defaultPose));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves collision slot zero for every post-AnimPartChanged identity.
|
||||
/// This intentionally has no visual-LOD option or Setup-type input.
|
||||
/// </summary>
|
||||
public static uint[] ResolveEffectivePartIdentities(
|
||||
IReadOnlyList<uint> postAnimPartGfxObjIds,
|
||||
Func<uint, uint> resolveSlotZero)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(postAnimPartGfxObjIds);
|
||||
ArgumentNullException.ThrowIfNull(resolveSlotZero);
|
||||
var result = new uint[postAnimPartGfxObjIds.Count];
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
result[i] = resolveSlotZero(postAnimPartGfxObjIds[i]);
|
||||
return result;
|
||||
}
|
||||
|
||||
public LiveEntityCollisionRegistration? Build(
|
||||
WorldEntity entity,
|
||||
Setup setup,
|
||||
IReadOnlyList<uint> effectivePartGfxObjIds,
|
||||
WorldSession.EntitySpawn spawn,
|
||||
LiveEntityRecord exactRecord,
|
||||
Vector3 worldOrigin,
|
||||
bool retainEmptyPayload = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(effectivePartGfxObjIds);
|
||||
ArgumentNullException.ThrowIfNull(exactRecord);
|
||||
if (spawn.Position is not { } position)
|
||||
return null;
|
||||
if (spawn.Guid != exactRecord.ServerGuid
|
||||
|| spawn.InstanceSequence != exactRecord.Generation
|
||||
|| !ReferenceEquals(exactRecord.WorldEntity, entity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Live collision construction requires the exact materialized record.");
|
||||
}
|
||||
|
||||
float scale = spawn.ObjScale ?? 1f;
|
||||
IReadOnlyList<Frame>? defaultPose = _defaultPose.Resolve(
|
||||
spawn.MotionTableId ?? 0u,
|
||||
setup.Parts.Count);
|
||||
IReadOnlyList<ShadowShape> raw = ShadowShapeBuilder.FromSetup(
|
||||
setup,
|
||||
scale,
|
||||
_hasPhysicsBsp,
|
||||
partPoseOverride: defaultPose,
|
||||
effectivePartGfxObjIds: effectivePartGfxObjIds);
|
||||
var shapes = new List<ShadowShape>(raw.Count);
|
||||
foreach (ShadowShape shape in raw)
|
||||
{
|
||||
if (shape.CollisionType == ShadowCollisionType.BSP)
|
||||
{
|
||||
float radius = (_physicsBspRadius(shape.GfxObjId) ?? 2f) * scale;
|
||||
shapes.Add(shape with { Radius = radius });
|
||||
}
|
||||
else
|
||||
{
|
||||
shapes.Add(shape);
|
||||
}
|
||||
}
|
||||
|
||||
if (shapes.Count == 0 && setup.Radius > 0.0001f)
|
||||
{
|
||||
shapes.Add(new ShadowShape(
|
||||
GfxObjId: 0u,
|
||||
LocalPosition: Vector3.Zero,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: scale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
Radius: setup.Radius * scale,
|
||||
CylHeight: (setup.Height > 0f ? setup.Height : setup.Radius * 2f) * scale));
|
||||
}
|
||||
|
||||
if (shapes.Count == 0 && !retainEmptyPayload)
|
||||
return null;
|
||||
|
||||
EntityCollisionFlags flags = EntityCollisionFlags.HasWeenie;
|
||||
if (spawn.ObjectDescriptionFlags is { } descriptionFlags)
|
||||
flags |= EntityCollisionFlagsExt.FromPwdBitfield(descriptionFlags);
|
||||
if (spawn.ItemType == (uint)ItemType.Creature)
|
||||
flags |= EntityCollisionFlags.IsCreature;
|
||||
|
||||
return new LiveEntityCollisionRegistration(
|
||||
entity.Id,
|
||||
entity.SourceGfxObjOrSetupId,
|
||||
entity.Position,
|
||||
entity.Rotation,
|
||||
shapes,
|
||||
(uint)exactRecord.FinalPhysicsState,
|
||||
flags,
|
||||
worldOrigin.X,
|
||||
worldOrigin.Y,
|
||||
position.LandblockId,
|
||||
position.LandblockId);
|
||||
}
|
||||
|
||||
public static void Register(
|
||||
ShadowObjectRegistry registry,
|
||||
LiveEntityCollisionRegistration registration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
registry.RegisterMultiPart(
|
||||
registration.EntityId,
|
||||
registration.EntityWorldPosition,
|
||||
registration.EntityWorldRotation,
|
||||
registration.Shapes,
|
||||
registration.State,
|
||||
registration.Flags,
|
||||
registration.WorldOffsetX,
|
||||
registration.WorldOffsetY,
|
||||
registration.LandblockId,
|
||||
registration.SeedCellId,
|
||||
isStatic: false);
|
||||
|
||||
if (!PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
return;
|
||||
|
||||
int cylinders = 0;
|
||||
int bsps = 0;
|
||||
foreach (ShadowShape shape in registration.Shapes)
|
||||
{
|
||||
if (shape.CollisionType == ShadowCollisionType.Cylinder)
|
||||
cylinders++;
|
||||
else
|
||||
bsps++;
|
||||
}
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[entity-source] id=0x{registration.EntityId:X8} entityId=0x{registration.EntityId:X8} src=0x{registration.SourceId:X8} gfxObj=0x{registration.SourceId:X8} lb=0x{registration.LandblockId:X8} shapes=cyl{cylinders}+bsp{bsps} note=server-spawn-root state=0x{registration.State:X8} flags={registration.Flags}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits a retail ObjDesc part-array replacement. A shapeless result
|
||||
/// removes the prior payload; a shaped result replaces it without making
|
||||
/// a Hidden, parented, or cell-less object collide in world cells.
|
||||
/// </summary>
|
||||
public static void ReconcileAppearance(
|
||||
ShadowObjectRegistry registry,
|
||||
uint entityId,
|
||||
LiveEntityCollisionRegistration? registration,
|
||||
bool suspendIfNew)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
if (registration is null)
|
||||
return;
|
||||
|
||||
if (registration.EntityId != entityId)
|
||||
throw new InvalidOperationException("Collision replacement belongs to a different live entity.");
|
||||
|
||||
registry.ReplaceMultiPartPayload(
|
||||
registration.EntityId,
|
||||
registration.EntityWorldPosition,
|
||||
registration.EntityWorldRotation,
|
||||
registration.Shapes,
|
||||
registration.State,
|
||||
registration.Flags,
|
||||
registration.WorldOffsetX,
|
||||
registration.WorldOffsetY,
|
||||
registration.LandblockId,
|
||||
registration.SeedCellId,
|
||||
isStatic: false,
|
||||
suspendIfNew);
|
||||
}
|
||||
}
|
||||
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
59
src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the motion table's authored default-state part pose used by a
|
||||
/// live object's collision PartArray. Retail keeps the collision parts in the
|
||||
/// same current pose as the visual PartArray; an unresolved table falls back
|
||||
/// to Setup placement frames in <see cref="ShadowShapeBuilder"/>.
|
||||
/// The retained default-pose snapshot approximation is tracked as AP-84 in
|
||||
/// the retail divergence register until live per-frame collision poses land.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityDefaultPoseResolver
|
||||
{
|
||||
private readonly Func<uint, MotionTable?> _loadMotionTable;
|
||||
private readonly IAnimationLoader _animationLoader;
|
||||
private readonly bool _dumpMotion;
|
||||
|
||||
public LiveEntityDefaultPoseResolver(
|
||||
Func<uint, MotionTable?> loadMotionTable,
|
||||
IAnimationLoader animationLoader,
|
||||
bool dumpMotion)
|
||||
{
|
||||
_loadMotionTable = loadMotionTable
|
||||
?? throw new ArgumentNullException(nameof(loadMotionTable));
|
||||
_animationLoader = animationLoader
|
||||
?? throw new ArgumentNullException(nameof(animationLoader));
|
||||
_dumpMotion = dumpMotion;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Frame>? Resolve(uint motionTableId, int partCount)
|
||||
{
|
||||
if (motionTableId == 0u || partCount == 0)
|
||||
return null;
|
||||
|
||||
MotionTable? motionTable = _loadMotionTable(motionTableId);
|
||||
if (motionTable is null)
|
||||
return null;
|
||||
|
||||
IReadOnlyList<Frame>? pose = MotionTablePose.DefaultStatePartFrames(
|
||||
motionTable,
|
||||
_animationLoader.LoadAnimation);
|
||||
|
||||
if (_dumpMotion)
|
||||
{
|
||||
string description = pose is null
|
||||
? "null->placement-fallback"
|
||||
: FormattableString.Invariant(
|
||||
$"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})");
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {description}"));
|
||||
}
|
||||
|
||||
return pose;
|
||||
}
|
||||
}
|
||||
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal file
209
src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
internal readonly record struct AcceptedMotionNetworkUpdate(
|
||||
WorldSession.EntityMotionUpdate Update,
|
||||
LiveEntityRecord Record,
|
||||
AcceptedPhysicsTimestamps Timestamps,
|
||||
ulong MovementAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion);
|
||||
|
||||
internal readonly record struct AcceptedVectorNetworkUpdate(
|
||||
VectorUpdate.Parsed Update,
|
||||
LiveEntityRecord Record,
|
||||
ulong VectorAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion);
|
||||
|
||||
internal readonly record struct AcceptedStateNetworkUpdate(
|
||||
SetState.Parsed Update,
|
||||
LiveEntityRecord Record,
|
||||
ulong StateAuthorityVersion);
|
||||
|
||||
internal readonly record struct AcceptedPositionNetworkUpdate(
|
||||
WorldSession.EntityPositionUpdate Update,
|
||||
LiveEntityRecord Record,
|
||||
WorldSession.EntitySpawn Spawn,
|
||||
AcceptedPhysicsTimestamps Timestamps,
|
||||
PositionTimestampDisposition TimestampDisposition,
|
||||
ulong PositionAuthorityVersion,
|
||||
ulong VelocityAuthorityVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Owns the one retail timestamp/authority admission edge for live inbound
|
||||
/// Movement, Vector, State, and Position packets. The accepted context pins
|
||||
/// the exact record and channel versions before any re-entrant App callback
|
||||
/// can run; channel presenters must revalidate those captures after callbacks.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityInboundAuthorityGate
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly Action<uint, AcceptedPhysicsTimestamps> _publishTimestamps;
|
||||
|
||||
public LiveEntityInboundAuthorityGate(
|
||||
LiveEntityRuntime liveEntities,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_publishTimestamps = publishTimestamps
|
||||
?? throw new ArgumentNullException(nameof(publishTimestamps));
|
||||
}
|
||||
|
||||
internal uint? LastLivePlayerLandblockId { get; private set; }
|
||||
|
||||
internal void ResetSessionState() => LastLivePlayerLandblockId = null;
|
||||
|
||||
internal bool TryAcceptMotion(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
bool retainPayload,
|
||||
out AcceptedMotionNetworkUpdate accepted,
|
||||
out bool timestampAccepted)
|
||||
{
|
||||
accepted = default;
|
||||
timestampAccepted = _liveEntities.TryApplyMotion(
|
||||
update,
|
||||
retainPayload,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps);
|
||||
if (!timestampAccepted)
|
||||
return false;
|
||||
|
||||
LiveEntityRecord? record = null;
|
||||
ulong movementAuthorityVersion = 0;
|
||||
ulong velocityAuthorityVersion = 0;
|
||||
if (retainPayload)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(update.Guid, out record))
|
||||
return false;
|
||||
movementAuthorityVersion = record.MovementAuthorityVersion;
|
||||
velocityAuthorityVersion = record.VelocityAuthorityVersion;
|
||||
}
|
||||
|
||||
_publishTimestamps(update.Guid, timestamps);
|
||||
if (!retainPayload)
|
||||
return false;
|
||||
if (!_liveEntities.IsCurrentMovementAuthority(
|
||||
record!,
|
||||
movementAuthorityVersion)
|
||||
|| !_liveEntities.IsCurrentVelocityAuthority(
|
||||
record!,
|
||||
velocityAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = new AcceptedMotionNetworkUpdate(
|
||||
update,
|
||||
record!,
|
||||
timestamps,
|
||||
movementAuthorityVersion,
|
||||
velocityAuthorityVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryAcceptVector(
|
||||
VectorUpdate.Parsed update,
|
||||
bool payloadIsValid,
|
||||
out AcceptedVectorNetworkUpdate accepted)
|
||||
{
|
||||
accepted = default;
|
||||
if (!payloadIsValid
|
||||
|| !_liveEntities.TryApplyVector(update, out _)
|
||||
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = new AcceptedVectorNetworkUpdate(
|
||||
update,
|
||||
record,
|
||||
record.VectorAuthorityVersion,
|
||||
record.VelocityAuthorityVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryAcceptState(
|
||||
SetState.Parsed update,
|
||||
out AcceptedStateNetworkUpdate accepted)
|
||||
{
|
||||
accepted = default;
|
||||
if (!_liveEntities.TryApplyState(update, out _, out _)
|
||||
|| !_liveEntities.TryGetRecord(update.Guid, out LiveEntityRecord record))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = new AcceptedStateNetworkUpdate(
|
||||
update,
|
||||
record,
|
||||
record.StateAuthorityVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryAcceptPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
uint localPlayerGuid,
|
||||
Quaternion? forcePositionRotation,
|
||||
Vector3? currentLocalVelocity,
|
||||
bool payloadIsValid,
|
||||
out AcceptedPositionNetworkUpdate accepted)
|
||||
{
|
||||
accepted = default;
|
||||
if (!payloadIsValid)
|
||||
return false;
|
||||
|
||||
bool isLocalPlayer = update.Guid == localPlayerGuid;
|
||||
bool known = _liveEntities.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer,
|
||||
isLocalPlayer ? forcePositionRotation : null,
|
||||
isLocalPlayer ? currentLocalVelocity : null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn spawn,
|
||||
out AcceptedPhysicsTimestamps timestamps);
|
||||
if (!known)
|
||||
{
|
||||
if (isLocalPlayer)
|
||||
LastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
return false;
|
||||
}
|
||||
|
||||
LiveEntityRecord? record = null;
|
||||
ulong positionAuthorityVersion = 0;
|
||||
ulong velocityAuthorityVersion = 0;
|
||||
if (disposition is not PositionTimestampDisposition.Rejected)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(update.Guid, out record))
|
||||
return false;
|
||||
positionAuthorityVersion = record.PositionAuthorityVersion;
|
||||
velocityAuthorityVersion = record.VelocityAuthorityVersion;
|
||||
}
|
||||
|
||||
_publishTimestamps(update.Guid, timestamps);
|
||||
if (disposition is PositionTimestampDisposition.Rejected)
|
||||
return false;
|
||||
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||
record!,
|
||||
positionAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = new AcceptedPositionNetworkUpdate(
|
||||
update,
|
||||
record!,
|
||||
spawn,
|
||||
timestamps,
|
||||
disposition,
|
||||
positionAuthorityVersion,
|
||||
velocityAuthorityVersion);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void ObserveAcceptedLocalPosition(uint landblockId) =>
|
||||
LastLivePlayerLandblockId = landblockId;
|
||||
}
|
||||
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal file
488
src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
using AcDream.App.Interaction;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Focused owner of reusable live CPhysicsObj-style host lookup, remote motion
|
||||
/// binding, server MoveTo/sticky routing, setup-cylinder dimensions, and
|
||||
/// Hidden target invalidation. It is independent of packet orchestration so
|
||||
/// renderer, selection, and movement collaborators share one one-way service.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityMotionRuntimeController
|
||||
: ILiveEntityMotionRuntimeBindings
|
||||
{
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly PhysicsDataCache _physicsDataCache;
|
||||
private readonly Func<SelectionInteractionController?> _selectionInteractions;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
|
||||
private IReadOnlyDictionary<uint, WorldEntity> _entitiesByServerGuid =>
|
||||
_liveEntities.MaterializedWorldEntities;
|
||||
private IReadOnlyDictionary<uint, WorldEntity> _visibleEntitiesByServerGuid =>
|
||||
_liveEntities.WorldEntities;
|
||||
|
||||
public LiveEntityMotionRuntimeController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsDataCache physicsDataCache,
|
||||
Func<SelectionInteractionController?> selectionInteractions,
|
||||
SelectionState selection,
|
||||
LiveWorldOriginState origin)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
|
||||
_selectionInteractions = selectionInteractions ?? throw new ArgumentNullException(nameof(selectionInteractions));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
}
|
||||
internal AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||||
RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid)
|
||||
{
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer;
|
||||
if (sequencer is not null && rm.Sink is null)
|
||||
{
|
||||
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(sequencer);
|
||||
rm.Motion.DefaultSink = rm.Sink;
|
||||
}
|
||||
// #174 (2026-07-05): the RemoveLinkAnimations seam is retail
|
||||
// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to
|
||||
// CPartArray::HandleEnterWorld 0x00517d70 →
|
||||
// MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the
|
||||
// sequence's link animations AND drain pending_animations
|
||||
// completely (each pop fires MotionDone → CMotionInterp pops its
|
||||
// pending_motions node in lockstep). The previous binding was the
|
||||
// bare sequence strip — every LeaveGround (jump) stripped the
|
||||
// animations that queued manager nodes were counting down on,
|
||||
// orphaning them (NumAnims>0, anims gone) and permanently damming
|
||||
// BOTH queues: MotionsPending() never drained again, so every
|
||||
// armed moveto (ACE's walk-to-door mt-6, the close-range use turn)
|
||||
// starved — the "door only works on a fresh session" bug.
|
||||
if (sequencer is not null)
|
||||
{
|
||||
rm.Motion.RemoveLinkAnimations = () => sequencer.Manager.HandleEnterWorld();
|
||||
rm.Motion.InitializeMotionTables = () => sequencer.Manager.InitializeState();
|
||||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||||
rm.Motion.CheckForCompletedMotions = sequencer.Manager.CheckForCompletedMotions;
|
||||
}
|
||||
|
||||
// Host/MoveTo ownership does not depend on a render animation. AP-77
|
||||
// deliberately supplies CMotionInterp's headless body fallback when a
|
||||
// live object has no PartArray sink, so build the manager once anyway.
|
||||
if (rm.Host is not null)
|
||||
return rm.Sink;
|
||||
if (_liveEntities is not { } liveEntities
|
||||
|| !liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord hostRecord)
|
||||
|| !ReferenceEquals(hostRecord.RemoteMotionRuntime, rm))
|
||||
{
|
||||
return rm.Sink;
|
||||
}
|
||||
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
|
||||
// wiring (the conformance-tested reference). Positions are WORLD
|
||||
// space on both sides (getPosition + the MovementStruct positions
|
||||
// the UM router builds) — one consistent space, so the manager's
|
||||
// distance/heading math matches the harness exactly.
|
||||
// R5-V5: the construction is now the MovementManager facade's
|
||||
// MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
|
||||
// backpointers retail's MakeMoveToManager 0x00524000 constructs
|
||||
// from); MakeMoveToManager() below invokes it once, preserving the
|
||||
// pre-facade eager timing.
|
||||
var rmT = rm;
|
||||
var mtBody = rm.Body;
|
||||
// R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
|
||||
// GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
|
||||
// (a dictionary hit) so the read never races the spawn path's
|
||||
// CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
|
||||
// (own side) and StickyManager::adjust_offset's own-radius gap term.
|
||||
// Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
|
||||
// R5-V3").
|
||||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity))
|
||||
return rm.Sink;
|
||||
// R5-V2: forward-declared so the MoveToManager's target seams route
|
||||
// into the entity's TargetManager (retail CPhysicsObj::set_target →
|
||||
// TargetManager::SetTarget). Assigned right after the manager is built.
|
||||
EntityPhysicsHost host = null!;
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
rm.Movement.MoveToFactory = () =>
|
||||
{
|
||||
var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
|
||||
rm.Motion,
|
||||
stopCompletely: () => rmT.Movement.PerformMovement(
|
||||
new AcDream.Core.Physics.MovementStruct
|
||||
{
|
||||
Type = AcDream.Core.Physics.MovementType.StopCompletely,
|
||||
}),
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
rmT.CellId, mtBody.Position, mtBody.Orientation),
|
||||
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
|
||||
mtBody.Orientation),
|
||||
setHeading: (h, _) => mtBody.Orientation =
|
||||
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
|
||||
getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||||
getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
|
||||
contact: () => mtBody.OnWalkable,
|
||||
isInterpolating: () => rmT.Interp.IsActive,
|
||||
getVelocity: () => mtBody.Velocity,
|
||||
getSelfId: () => serverGuid,
|
||||
// R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
|
||||
// → the entity's TargetManager (replaces the AP-79 TrackedTarget*
|
||||
// poll). The manager passes (0, top_level_id, 0.5, 0.0).
|
||||
setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
|
||||
clearTarget: () => host.ClearTarget(),
|
||||
getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
|
||||
setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
|
||||
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
|
||||
curTime: NowSeconds);
|
||||
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
|
||||
// PositionManager (host is constructed before MakeMoveToManager
|
||||
// invokes this factory) —
|
||||
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
|
||||
// (retail MoveToManager::BeginNextNode @0x00529d3a);
|
||||
// * PerformMovement's head unstick: unstick_from_object →
|
||||
// PositionManager::UnStick (MoveToManager.PerformMovement:414).
|
||||
mtm.StickTo = (tlid, radius, height) =>
|
||||
host.PositionManager.StickTo(tlid, radius, height);
|
||||
mtm.Unstick = host.PositionManager.UnStick;
|
||||
return mtm;
|
||||
};
|
||||
// TS-36 (remote side): the interp's interrupt seam is retail's
|
||||
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
|
||||
// chain (V2's reentrancy tests prove the wiring is inert-safe;
|
||||
// R5-V5: the chain now lands on the literal facade relay 0x005241b0).
|
||||
rm.Motion.InterruptCurrentMovement =
|
||||
() => rmT.Movement.CancelMoveTo(
|
||||
AcDream.Core.Physics.WeenieError.ActionCancelled);
|
||||
|
||||
// R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
|
||||
// is the entity's WORLD position (WorldEntity.Position — exactly the
|
||||
// source the AP-79 poll used for a tracked target, so the voyeur system
|
||||
// delivers the identical position for a moveto's quantum-0 case).
|
||||
// HandleTargetting ticks per frame (added in the per-remote loop);
|
||||
// HandleUpdateTarget fans deliveries to this entity's MoveToManager
|
||||
// and (R5-V3, inside the host) its PositionManager — retail's
|
||||
// CPhysicsObj::HandleUpdateTarget order.
|
||||
var configuredHost = new EntityPhysicsHost(
|
||||
serverGuid,
|
||||
getPosition: () => new AcDream.Core.Physics.Position(
|
||||
hostRecord.FullCellId,
|
||||
hostRecord.WorldEntity?.Position ?? mtBody.Position,
|
||||
mtBody.Orientation),
|
||||
getVelocity: () => mtBody.Velocity,
|
||||
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||||
inContact: () => mtBody.OnWalkable,
|
||||
minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
|
||||
curTime: NowSeconds,
|
||||
physicsTimerTime: NowSeconds,
|
||||
getObjectA: ResolvePhysicsHost,
|
||||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
|
||||
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
|
||||
// relay to the moveto side); the host chains the PositionManager
|
||||
// leg after it.
|
||||
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||||
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||||
host = EntityPhysicsHost.InstallOrRebind(
|
||||
liveEntities,
|
||||
hostRecord,
|
||||
configuredHost);
|
||||
rm.MarkFullPhysicsHostBound();
|
||||
|
||||
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
|
||||
// MoveToManager via the factory above (host now exists for the
|
||||
// sticky binds inside it). The UM funnel head's unstick
|
||||
// (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
|
||||
// routing sites) binds on the interp beside it.
|
||||
rm.Movement.MakeMoveToManager();
|
||||
rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
|
||||
return rm.Sink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — retail <c>CObjectMaint::GetObjectA(id)</c>: resolve any entity's
|
||||
/// <see cref="AcDream.Core.Physics.Motion.IPhysicsObjHost"/> by guid, the
|
||||
/// cross-entity seam every host's <c>GetObjectA</c> uses. If the entity has
|
||||
/// a bound host (from <see cref="EnsureRemoteMotionBindings"/> or
|
||||
/// <c>EnterPlayerModeNow</c>) return it; otherwise, for any entity that
|
||||
/// EXISTS in the world, lazily create a minimal position-only host. Retail
|
||||
/// gives every <c>CPhysicsObj</c> the capacity to host a
|
||||
/// <c>target_manager</c> — a STATIC object (chest, corpse) must still answer
|
||||
/// <c>add_voyeur</c> so a mover can moveto/stick to it (without this,
|
||||
/// auto-walk to a never-animated object would arm the moveto but never
|
||||
/// receive the immediate target snapshot, and never start). The minimal
|
||||
/// host is registered so subsequent lookups reuse it; it is NOT ticked
|
||||
/// (a static object never drifts, so the AddVoyeur immediate snapshot is
|
||||
/// all a watcher needs; despawn still fires ExitWorld via the registry).
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||||
{
|
||||
if (_liveEntities is not { } liveEntities)
|
||||
return null;
|
||||
|
||||
bool isActive = liveEntities.TryGetRecord(id, out LiveEntityRecord activeRecord);
|
||||
if (!isActive)
|
||||
return null;
|
||||
|
||||
// TS-49: CObjCell::hide_object removes Hidden objects from the
|
||||
// relationship surface until DetectionManager is ported. Pending,
|
||||
// attached, and otherwise non-render-visible active objects remain in
|
||||
// CObjectMaint's object table and must still resolve here.
|
||||
if (liveEntities.IsHidden(id))
|
||||
{
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[autowalk-host-miss] object=0x{id:X8} "
|
||||
+ $"materialized={_entitiesByServerGuid.ContainsKey(id)} "
|
||||
+ $"registered={liveEntities.TryGetPhysicsHost(id, out _)} "
|
||||
+ $"hidden={liveEntities.IsHidden(id)}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (liveEntities.TryGetPhysicsHost(id, out var existing))
|
||||
return existing;
|
||||
|
||||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
var minimal = EntityPhysicsHost.CreateMinimal(
|
||||
activeRecord,
|
||||
ResolvePhysicsHost,
|
||||
NowSeconds);
|
||||
liveEntities.InstallPhysicsHost(activeRecord, minimal);
|
||||
return minimal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 (#171): retail <c>CPartArray::GetRadius</c>/<c>GetHeight</c>
|
||||
/// (0x005180a0/0x005180b0 — <c>setup->radius/height × scale</c>; ACE
|
||||
/// <c>PartArray.cs:189-207</c> reads the same Setup-level fields). Returns
|
||||
/// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
|
||||
/// ACE's <c>PartArray != null ? … : 0</c> fallback shape
|
||||
/// (<c>PhysicsObj.cs:951-952</c>). Live spawns cache their Setup in
|
||||
/// <c>_physicsDataCache</c> at spawn time (CacheSetup, ~3250), so this is
|
||||
/// a dictionary hit for every creature.
|
||||
/// </summary>
|
||||
public (float Radius, float Height) GetSetupCylinder(
|
||||
uint serverGuid, AcDream.Core.World.WorldEntity entity)
|
||||
{
|
||||
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null)
|
||||
return (0f, 0f);
|
||||
// Live spawns bake ObjScale into MeshRefs and never populate
|
||||
// WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
|
||||
// authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
|
||||
// same source the collision registration's entScale uses (~4137).
|
||||
// entity.Scale remains the fallback for non-spawn entities (scenery —
|
||||
// never a chase/sticky participant). Diff-review find: without this,
|
||||
// a server-scaled creature variant read unscaled radii and kept the
|
||||
// #171 interpenetration exactly for scaled bodies.
|
||||
float scale =
|
||||
_liveEntities.Snapshots.TryGetValue(serverGuid, out var sp)
|
||||
&& sp.ObjScale is { } objScale && objScale > 0f
|
||||
? objScale
|
||||
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||||
return (setup.Radius * scale, setup.Height * scale);
|
||||
}
|
||||
|
||||
// #184 Slice 2a: ApplyPositionManagerDelta + SyncRemoteShadowToBody moved to
|
||||
// AcDream.App.Physics.RemotePhysicsUpdater. ApplyPositionManagerDelta had no
|
||||
// caller outside the DR tick; SyncRemoteShadowToBody is now called back via
|
||||
// _remotePhysicsUpdater from the NPC UP-branch tail (below).
|
||||
|
||||
/// <summary>
|
||||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||||
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||||
/// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
|
||||
/// radius/height (0 when shapeless — retail's null-PartArray arm), then
|
||||
/// <c>PositionManager::StickTo</c>. Unresolvable target → no stick at
|
||||
/// all (retail's GetObjectA-null path). Retail resolves the target's
|
||||
/// top-level PARENT first (<c>parent ?? self</c>); acdream's entity
|
||||
/// table is flat (no client-side parenting), so the guid is used as-is —
|
||||
/// the same convention as RouteServerMoveTo's TopLevelId.
|
||||
/// </summary>
|
||||
public void StickToObjectFromWire(
|
||||
AcDream.Core.Physics.Motion.IPhysicsObjHost? host,
|
||||
uint targetGuid)
|
||||
{
|
||||
if (host is not EntityPhysicsHost entityHost)
|
||||
return;
|
||||
if (_liveEntities is not { } liveEntities
|
||||
|| !liveEntities.TryGetInteractionEligibleEntity(targetGuid, out var tgtEnt))
|
||||
return;
|
||||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||||
entityHost.PositionManager.StickTo(targetGuid, radius, height);
|
||||
}
|
||||
|
||||
public void ClearTargetForHiddenEntity(uint serverGuid)
|
||||
{
|
||||
if (_selectionInteractions() is { } interactions)
|
||||
interactions.OnEntityHidden(serverGuid);
|
||||
else if (_selection.SelectedObjectId == serverGuid)
|
||||
_selection.Clear(
|
||||
AcDream.Core.Selection.SelectionChangeSource.System,
|
||||
AcDream.Core.Selection.SelectionChangeReason.Cleared);
|
||||
|
||||
if (_liveEntities?.TryGetPhysicsHost(serverGuid, out var hiddenHost) == true
|
||||
&& hiddenHost is EntityPhysicsHost hiddenEntityHost)
|
||||
hiddenEntityHost.NotifyHidden();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
|
||||
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
|
||||
/// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
|
||||
/// the interp (<c>my_run_rate</c>, unpack @300603/@300660 — plan M13),
|
||||
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
|
||||
/// against the entity table; unresolvable degrades to
|
||||
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
|
||||
/// NOT an error), and calls the R5-V5 facade's <c>PerformMovement</c>
|
||||
/// (<c>MovementManager::PerformMovement</c> 0x005240d0 — types 6-9
|
||||
/// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
|
||||
/// at each head, a no-op here since the bind sites create eagerly).
|
||||
/// Returns true when the event was a type-6..9 moveto (consumed); false
|
||||
/// for every other movement type (caller falls through to its funnel /
|
||||
/// skip posture).
|
||||
/// </summary>
|
||||
public bool RouteServerMoveTo(
|
||||
AcDream.Core.Physics.Motion.MovementManager movement,
|
||||
uint cellId,
|
||||
AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||||
{
|
||||
if (update.MotionState.IsServerControlledMoveTo
|
||||
&& update.MotionState.MoveToPath is { } path)
|
||||
{
|
||||
// my_run_rate write (unpack_movement @300603).
|
||||
if (update.MotionState.MoveToRunRate is { } mtRunRate)
|
||||
movement.Minterp.MyRunRate = mtRunRate;
|
||||
|
||||
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||||
.OriginToWorld(
|
||||
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||||
_origin.CenterX, _origin.CenterY);
|
||||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
|
||||
path.Bitfield,
|
||||
path.DistanceToObject,
|
||||
path.MinDistance,
|
||||
path.FailDistance,
|
||||
update.MotionState.MoveToSpeed ?? 1f,
|
||||
path.WalkRunThreshold,
|
||||
path.DesiredHeading);
|
||||
|
||||
var ms = new AcDream.Core.Physics.MovementStruct
|
||||
{
|
||||
Params = mp,
|
||||
};
|
||||
// mt 6 with a resolvable target → MoveToObject (the P4 tracker
|
||||
// feeds position updates per tick); else degrade to
|
||||
// MoveToPosition at the wire origin (§2f).
|
||||
if (update.MotionState.MovementType == 6
|
||||
&& path.TargetGuid is { } tgtGuid
|
||||
&& _liveEntities is { } liveMoveEntities
|
||||
&& liveMoveEntities.TryGetInteractionEligibleEntity(tgtGuid, out var tgtEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||||
ms.ObjectId = tgtGuid;
|
||||
ms.TopLevelId = tgtGuid;
|
||||
// R5-V3 (#171): retail resolves the TARGET object's PartArray
|
||||
// radius/height at the MoveToObject call site
|
||||
// (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
|
||||
// PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
|
||||
// Height, feeding GetCurrentDistance's edge-to-edge arrival
|
||||
// (UseSpheres) and the sticky-arrival handoff's target radius.
|
||||
// Was unset (0): every attacker closed ~one body-radius deeper
|
||||
// than retail before stopping — the #171 dogpile term.
|
||||
(ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
|
||||
ms.Pos = new AcDream.Core.Physics.Position(
|
||||
cellId, tgtEnt.Position,
|
||||
System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
|
||||
ms.Pos = new AcDream.Core.Physics.Position(
|
||||
cellId, destWorld,
|
||||
System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
movement.PerformMovement(ms);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (update.MotionState.IsServerControlledTurnTo
|
||||
&& update.MotionState.TurnToPath is { } turnPath)
|
||||
{
|
||||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
|
||||
turnPath.Bitfield,
|
||||
turnPath.Speed,
|
||||
turnPath.DesiredHeading);
|
||||
|
||||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||||
if (update.MotionState.MovementType == 8
|
||||
&& turnPath.TargetGuid is { } turnTgt
|
||||
&& _liveEntities is { } liveTurnEntities
|
||||
&& liveTurnEntities.TryGetInteractionEligibleEntity(turnTgt, out var turnEnt))
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||||
ms.ObjectId = turnTgt;
|
||||
ms.TopLevelId = turnTgt;
|
||||
ms.Pos = new AcDream.Core.Physics.Position(
|
||||
cellId, turnEnt.Position,
|
||||
System.Numerics.Quaternion.Identity);
|
||||
}
|
||||
else
|
||||
{
|
||||
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
|
||||
// Retail's mt-8 unresolvable-object fallback substitutes the
|
||||
// STANDALONE wire heading into the params before degrading
|
||||
// (decomp §2f case 8: `params.desired_heading = wire_heading`).
|
||||
// Invisible against ACE (P6: both heading fields written from
|
||||
// the same source) but required for the verbatim degrade —
|
||||
// closes the V4 carry-over the adversarial review caught
|
||||
// (TurnToPathData.WireHeading was parsed but never consumed).
|
||||
if (update.MotionState.MovementType == 8
|
||||
&& turnPath.WireHeading is { } wireHeading)
|
||||
{
|
||||
mp.DesiredHeading = wireHeading;
|
||||
}
|
||||
}
|
||||
movement.PerformMovement(ms);
|
||||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||||
{
|
||||
string target = turnPath.TargetGuid is { } targetGuid
|
||||
? $"0x{targetGuid:X8}" : "null";
|
||||
bool targetVisible = turnPath.TargetGuid is { } visibleGuid
|
||||
&& _visibleEntitiesByServerGuid.ContainsKey(visibleGuid);
|
||||
bool targetHost = turnPath.TargetGuid is { } hostGuid
|
||||
&& _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true;
|
||||
var moveTo = movement.MoveTo;
|
||||
Console.WriteLine(
|
||||
$"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} "
|
||||
+ $"routed={ms.Type} target={target} visible={targetVisible} "
|
||||
+ $"host={targetHost} stop={mp.StopCompletelyFlag} "
|
||||
+ $"initialized={moveTo?.Initialized ?? false} "
|
||||
+ $"nodes={moveTo?.PendingActions.Count() ?? 0} "
|
||||
+ $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} "
|
||||
+ $"pendingMotions={movement.Minterp.MotionsPending()}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// #184 Slice 2a: TickRemoteMoveTo (retail MovementManager::UseTime
|
||||
// 0x005242f0 — moveto steering / arrival / fail-distance; the P4 poll is
|
||||
// gone, TargetManager voyeur delivers positions) moved to
|
||||
// RemotePhysicsUpdater. The DR tick was its only caller.
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||||
/// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the
|
||||
/// new (stance, forward-command) override, and if the cycle is still
|
||||
/// animated, swap in the new animation/frame range. Entities not in
|
||||
/// the animated map (static props, entities rejected at spawn time)
|
||||
/// are simply ignored — there's nothing to tick for them.
|
||||
/// </summary>
|
||||
}
|
||||
1790
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
1790
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
Normal file
File diff suppressed because it is too large
Load diff
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal file
34
src/AcDream.App/Physics/LiveEntityVectorRoute.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
namespace AcDream.App.Physics;
|
||||
|
||||
internal enum LiveEntityVectorRoute
|
||||
{
|
||||
Projectile,
|
||||
CanonicalBody,
|
||||
OrdinaryRemote,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins retail's one-CPhysicsObj vector-update priority. A missile owns its
|
||||
/// canonical body first; otherwise a retained static body consumes the wire
|
||||
/// vector; only an ordinary remote reaches MovementManager storage.
|
||||
/// </summary>
|
||||
internal static class LiveEntityVectorRouter
|
||||
{
|
||||
internal static LiveEntityVectorRoute Route(
|
||||
Func<bool> tryProjectile,
|
||||
Func<bool> tryCanonicalBody,
|
||||
Action applyOrdinaryRemote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tryProjectile);
|
||||
ArgumentNullException.ThrowIfNull(tryCanonicalBody);
|
||||
ArgumentNullException.ThrowIfNull(applyOrdinaryRemote);
|
||||
|
||||
if (tryProjectile())
|
||||
return LiveEntityVectorRoute.Projectile;
|
||||
if (tryCanonicalBody())
|
||||
return LiveEntityVectorRoute.CanonicalBody;
|
||||
|
||||
applyOrdinaryRemote();
|
||||
return LiveEntityVectorRoute.OrdinaryRemote;
|
||||
}
|
||||
}
|
||||
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal file
29
src/AcDream.App/Physics/LocalForcePositionTransaction.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Preserves retail ForcePosition's atomic local order: validate the accepted
|
||||
/// Position authority, blip once, acknowledge once, then stop if the
|
||||
/// acknowledgement synchronously displaced that authority.
|
||||
/// </summary>
|
||||
internal static class LocalForcePositionTransaction
|
||||
{
|
||||
internal static bool Apply(
|
||||
bool isForcePosition,
|
||||
Func<bool> isCurrent,
|
||||
Action blip,
|
||||
Action acknowledge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(isCurrent);
|
||||
ArgumentNullException.ThrowIfNull(blip);
|
||||
ArgumentNullException.ThrowIfNull(acknowledge);
|
||||
|
||||
if (!isForcePosition)
|
||||
return true;
|
||||
if (!isCurrent())
|
||||
return false;
|
||||
|
||||
blip();
|
||||
acknowledge();
|
||||
return isCurrent();
|
||||
}
|
||||
}
|
||||
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
19
src/AcDream.App/Physics/LocalPlayerShadowState.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>Session-scoped cache of the local player's last published shadow pose.</summary>
|
||||
internal sealed class LocalPlayerShadowState
|
||||
{
|
||||
public readonly record struct Snapshot(
|
||||
Vector3 Position,
|
||||
Quaternion Orientation,
|
||||
uint CellId);
|
||||
|
||||
public Snapshot? Current { get; private set; }
|
||||
|
||||
public void Set(Vector3 position, Quaternion orientation, uint cellId) =>
|
||||
Current = new Snapshot(position, orientation, cellId);
|
||||
|
||||
public void Clear() => Current = null;
|
||||
}
|
||||
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
117
src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns publication and suspension of the local player's collision shadow.
|
||||
/// Identity/incarnation checks stay beside the cached shadow pose so player
|
||||
/// mode, projection, and teleport placement cannot publish different owners.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerShadowSynchronizer
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly LocalPlayerShadowState _state;
|
||||
|
||||
public LocalPlayerShadowSynchronizer(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
LiveWorldOriginState origin,
|
||||
LocalPlayerShadowState state)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_state = state ?? throw new ArgumentNullException(nameof(state));
|
||||
}
|
||||
|
||||
public void Sync(WorldEntity playerEntity, uint cellId, bool force = false)
|
||||
=> SyncPose(
|
||||
playerEntity,
|
||||
playerEntity.Position,
|
||||
playerEntity.Rotation,
|
||||
cellId,
|
||||
force);
|
||||
|
||||
public LocalPlayerShadowState.Snapshot? Capture() => _state.Current;
|
||||
|
||||
public void SyncPose(
|
||||
WorldEntity playerEntity,
|
||||
System.Numerics.Vector3 position,
|
||||
System.Numerics.Quaternion orientation,
|
||||
uint cellId,
|
||||
bool force = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
if (_liveEntities.IsHidden(_identity.ServerGuid)
|
||||
|| cellId == 0
|
||||
|| !IsCurrentVisibleProjection(playerEntity))
|
||||
{
|
||||
Suspend(playerEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force
|
||||
&& _state.Current is { } last
|
||||
&& last.CellId == cellId
|
||||
&& System.Numerics.Vector3.DistanceSquared(
|
||||
last.Position,
|
||||
position) <= 1e-4f
|
||||
&& MathF.Abs(System.Numerics.Quaternion.Dot(
|
||||
last.Orientation,
|
||||
orientation)) >= 0.99999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physics.ShadowObjects,
|
||||
playerEntity.Id,
|
||||
position,
|
||||
orientation,
|
||||
cellId,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY);
|
||||
_state.Set(position, orientation, cellId);
|
||||
}
|
||||
|
||||
public void Restore(
|
||||
WorldEntity playerEntity,
|
||||
LocalPlayerShadowState.Snapshot? snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
if (snapshot is not { } prior || !IsCurrentVisibleProjection(playerEntity))
|
||||
{
|
||||
Suspend(playerEntity);
|
||||
return;
|
||||
}
|
||||
|
||||
SyncPose(
|
||||
playerEntity,
|
||||
prior.Position,
|
||||
prior.Orientation,
|
||||
prior.CellId,
|
||||
force: true);
|
||||
}
|
||||
|
||||
public bool IsCurrentVisibleProjection(WorldEntity playerEntity) =>
|
||||
_liveEntities.TryGetRecord(playerEntity.ServerGuid, out LiveEntityRecord record)
|
||||
&& ReferenceEquals(record.WorldEntity, playerEntity)
|
||||
&& _liveEntities.IsCurrentSpatialRootObject(record);
|
||||
|
||||
public void Suspend(WorldEntity playerEntity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(playerEntity);
|
||||
_physics.ShadowObjects.Suspend(playerEntity.Id);
|
||||
_state.Clear();
|
||||
}
|
||||
|
||||
public void ResetSession() => _state.Clear();
|
||||
}
|
||||
|
|
@ -1,14 +1,39 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
internal interface IProjectileSetupResolver
|
||||
{
|
||||
Setup? Resolve(uint setupId);
|
||||
}
|
||||
|
||||
internal sealed class DatProjectileSetupResolver : IProjectileSetupResolver
|
||||
{
|
||||
private readonly IDatReaderWriter _dats;
|
||||
private readonly object _datLock;
|
||||
|
||||
public DatProjectileSetupResolver(IDatReaderWriter dats, object datLock)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
|
||||
}
|
||||
|
||||
public Setup? Resolve(uint setupId)
|
||||
{
|
||||
lock (_datLock)
|
||||
return _dats.Get<Setup>(setupId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread presentation owner for live retail missiles. Logical identity,
|
||||
/// generation, accepted network state, and teardown remain owned by
|
||||
|
|
@ -56,26 +81,26 @@ internal sealed class ProjectileController
|
|||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ProjectilePhysicsStepper _stepper;
|
||||
private readonly ShadowObjectRegistry _shadows;
|
||||
private readonly Func<uint, Setup?> _setupResolver;
|
||||
private readonly Action<WorldEntity> _publishRootPose;
|
||||
private readonly Func<(int X, int Y)> _liveCenterProvider;
|
||||
private readonly IProjectileSetupResolver? _setupResolver;
|
||||
private readonly IEntityRootPosePublisher? _rootPoses;
|
||||
private readonly LiveWorldOriginState? _origin;
|
||||
private readonly List<LiveEntityRecord> _spatialProjectileSnapshot = new();
|
||||
private double _lastFiniteGameTime;
|
||||
|
||||
internal ProjectileController(
|
||||
LiveEntityRuntime liveEntities,
|
||||
PhysicsEngine physicsEngine,
|
||||
Func<uint, Setup?>? setupResolver = null,
|
||||
Action<WorldEntity>? publishRootPose = null,
|
||||
Func<(int X, int Y)>? liveCenterProvider = null)
|
||||
IProjectileSetupResolver? setupResolver = null,
|
||||
IEntityRootPosePublisher? rootPoses = null,
|
||||
LiveWorldOriginState? origin = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
ArgumentNullException.ThrowIfNull(physicsEngine);
|
||||
_stepper = new ProjectilePhysicsStepper(physicsEngine);
|
||||
_shadows = physicsEngine.ShadowObjects;
|
||||
_setupResolver = setupResolver ?? (_ => null);
|
||||
_publishRootPose = publishRootPose ?? (_ => { });
|
||||
_liveCenterProvider = liveCenterProvider ?? (() => (0, 0));
|
||||
_setupResolver = setupResolver;
|
||||
_rootPoses = rootPoses;
|
||||
_origin = origin;
|
||||
_liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged;
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +347,7 @@ internal sealed class ProjectileController
|
|||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
_publishRootPose(entity);
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -472,7 +497,7 @@ internal sealed class ProjectileController
|
|||
// A newer State packet can make an already materialized object a
|
||||
// missile. TryBind adopts an existing MovementManager body so the
|
||||
// live record still represents retail's one CPhysicsObj.
|
||||
Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId);
|
||||
Setup? setup = _setupResolver?.Resolve(entity.SourceGfxObjOrSetupId);
|
||||
if (setup is null)
|
||||
{
|
||||
DiagnosticSink?.Invoke(
|
||||
|
|
@ -617,7 +642,7 @@ internal sealed class ProjectileController
|
|||
{
|
||||
SuspendOutsideWorld(runtime, entity.Id);
|
||||
}
|
||||
_publishRootPose(entity);
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -712,7 +737,7 @@ internal sealed class ProjectileController
|
|||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
}
|
||||
|
||||
if (IsHidden(record))
|
||||
|
|
@ -909,7 +934,7 @@ internal sealed class ProjectileController
|
|||
current.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
if (!IsCurrentQuantumIdentity(step))
|
||||
return false;
|
||||
}
|
||||
|
|
@ -966,7 +991,8 @@ internal sealed class ProjectileController
|
|||
// not lazily on the next simulated quantum: a zero-velocity or
|
||||
// newly ordinary retained projectile may have no later projectile
|
||||
// step from which to repair its shadow membership.
|
||||
(int liveCenterX, int liveCenterY) = _liveCenterProvider();
|
||||
int liveCenterX = _origin?.CenterX ?? 0;
|
||||
int liveCenterY = _origin?.CenterY ?? 0;
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_shadows,
|
||||
entity.Id,
|
||||
|
|
@ -975,7 +1001,7 @@ internal sealed class ProjectileController
|
|||
record.FullCellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
_publishRootPose(entity);
|
||||
_rootPoses?.UpdateRoot(entity);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
345
src/AcDream.App/Physics/RemoteMotion.cs
Normal file
345
src/AcDream.App/Physics/RemoteMotion.cs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
using AcDream.App.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
/// retail's client-side motion pipeline to every remote. Mirrors retail
|
||||
/// <c>CPhysicsObj::update_object</c> (0x00515D10) →
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) →
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512C30) →
|
||||
/// <c>CPhysicsObj::UpdatePhysicsInternal</c> (0x00510700), and ACE's
|
||||
/// <c>PhysicsObj.cs</c> port.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has NO special "interpolator" for remote entities — it runs
|
||||
/// the full motion state machine on every entity, local or remote,
|
||||
/// and reconciles via hard-snap on UpdatePosition. This class simply
|
||||
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
|
||||
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
|
||||
/// remote gets the same treatment as the local player.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class RemoteMotion :
|
||||
AcDream.App.World.ILiveEntityRemotePlacementRuntime,
|
||||
AcDream.App.World.ILiveEntityCanonicalRuntimeConsumer
|
||||
{
|
||||
public AcDream.Core.Physics.PhysicsBody Body { get; }
|
||||
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body;
|
||||
|
||||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||||
/// ONE per-entity owner of the interp + moveto pair (acclient.h
|
||||
/// <c>/* 3463 */</c>). Constructed here with the interp; the
|
||||
/// MoveToManager side arrives via <c>MoveToFactory</c> +
|
||||
/// <c>MakeMoveToManager()</c> in EnsureRemoteMotionBindings.
|
||||
/// <see cref="Motion"/>/<see cref="MoveTo"/> below are views of its
|
||||
/// children (retail <c>get_minterp</c>-style access), kept so the
|
||||
/// dozens of existing call sites read unchanged.</summary>
|
||||
public AcDream.Core.Physics.Motion.MovementManager Movement;
|
||||
|
||||
public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp;
|
||||
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
|
||||
/// once by EnsureRemoteMotionBindings; also bound as
|
||||
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
|
||||
/// cycles through the motion-table backend).</summary>
|
||||
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
|
||||
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
|
||||
public double LastServerPosTime;
|
||||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||||
public System.Numerics.Vector3 LastServerPos;
|
||||
/// <summary>
|
||||
/// #184: the body position at which this remote's collision SHADOW was
|
||||
/// last (re-)registered. The per-tick shadow-follows-resolved sync
|
||||
/// (<c>SyncRemoteShadowToBody</c>) skips re-flooding when the body has
|
||||
/// not moved > ε since this — a resting/idle-town crowd doesn't churn
|
||||
/// the registry, while a moving/de-overlapping crowd re-registers every
|
||||
/// tick. Sentinel <c>Zero</c> forces the first sync.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 LastShadowSyncPos;
|
||||
/// <summary>
|
||||
/// Complete root orientation paired with <see cref="LastShadowSyncPos"/>.
|
||||
/// Multipart and off-centre Setup collision geometry must be re-flooded
|
||||
/// when the object turns in place, not only when its origin translates.
|
||||
/// The default zero quaternion is the first-sync sentinel.
|
||||
/// </summary>
|
||||
public System.Numerics.Quaternion LastShadowSyncOrientation;
|
||||
/// <summary>
|
||||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||||
/// it for a server-controlled creature, derive it from consecutive
|
||||
/// authoritative positions instead of guessing from player RUM state.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 ServerVelocity;
|
||||
public bool HasServerVelocity;
|
||||
|
||||
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
|
||||
/// (server-directed movement), constructed + seam-bound by
|
||||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
|
||||
/// owned by <see cref="Movement"/>; this is the child view.</summary>
|
||||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo;
|
||||
|
||||
// R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager
|
||||
// voyeur system (retail CPhysicsObj::target_manager). Replaces the
|
||||
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
|
||||
// clear_target/quantum seams route here, HandleTargetting ticks per
|
||||
// frame, and OTHER entities resolve this one through LiveEntityRuntime's
|
||||
// exact-incarnation PhysicsHost slot.
|
||||
private Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?>? _physicsHostReader;
|
||||
private bool _fullPhysicsHostBound;
|
||||
public EntityPhysicsHost? Host => _fullPhysicsHostBound
|
||||
? _physicsHostReader?.Invoke() as EntityPhysicsHost
|
||||
: null;
|
||||
/// <summary>
|
||||
/// True while a server MoveToObject/MoveToPosition packet is the
|
||||
/// active locomotion source. Retail runs these through MoveToManager
|
||||
/// and CMotionInterp; the per-tick remote driver consults this to
|
||||
/// decide whether to feed body steering through
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||||
/// the InterpretedMotionState path.
|
||||
/// </summary>
|
||||
public bool ServerMoveToActive;
|
||||
|
||||
/// <summary>
|
||||
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||||
/// has been parsed and the world-converted destination is stored on
|
||||
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||||
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||||
/// Phase L.1c (2026-04-28).
|
||||
/// </summary>
|
||||
public bool HasMoveToDestination;
|
||||
|
||||
/// <summary>
|
||||
/// World-space destination from the most recent MoveTo packet's
|
||||
/// <c>Origin</c> field, converted via the same landblock-grid
|
||||
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||||
|
||||
/// <summary>
|
||||
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||||
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||||
/// the chase-arrival threshold per retail
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||||
/// </summary>
|
||||
public float MoveToMinDistance;
|
||||
|
||||
/// <summary>
|
||||
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||||
/// the flee branch (<c>move_away</c>); chase uses
|
||||
/// <see cref="MoveToMinDistance"/>.
|
||||
/// </summary>
|
||||
public float MoveToDistanceToObject;
|
||||
|
||||
/// <summary>
|
||||
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||||
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||||
/// chase. False = flee (<c>move_away</c>) or static target.
|
||||
/// </summary>
|
||||
public bool MoveToMoveTowards;
|
||||
|
||||
/// <summary>
|
||||
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
|
||||
/// for this entity. Used by the per-tick driver to give up
|
||||
/// steering when no refresh has arrived for
|
||||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||||
/// — typically because the entity left our streaming view and
|
||||
/// the server stopped broadcasting its MoveTo updates.
|
||||
/// </summary>
|
||||
public double LastMoveToPacketTime;
|
||||
/// <summary>
|
||||
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
|
||||
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
|
||||
/// 0x0040, indoor 0x0100+). Fed into
|
||||
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
|
||||
/// so the retail sphere-sweep can look up the right terrain/EnvCell
|
||||
/// polygons for each remote's per-frame motion. Zero until the first
|
||||
/// UP lands, which disables the transition step for that frame
|
||||
/// (Euler alone, matching pre-wire behavior).
|
||||
/// </summary>
|
||||
private uint _cellId;
|
||||
private Func<uint>? _canonicalCellReader;
|
||||
private Action<uint>? _canonicalCellWriter;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
|
||||
/// retain the local backing value. A MovementManager attached to an
|
||||
/// existing projectile delegates reads and writes to
|
||||
/// <see cref="LiveEntityRuntime"/>, so projectile prediction,
|
||||
/// Movement packets, and spatial rebucketing cannot develop competing
|
||||
/// cell identities.
|
||||
/// </summary>
|
||||
public uint CellId
|
||||
{
|
||||
get => _canonicalCellReader?.Invoke() ?? _cellId;
|
||||
set
|
||||
{
|
||||
_cellId = value;
|
||||
_canonicalCellWriter?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPosition
|
||||
{
|
||||
get => LastServerPos;
|
||||
set => LastServerPos = value;
|
||||
}
|
||||
|
||||
double AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastServerPositionTime
|
||||
{
|
||||
get => LastServerPosTime;
|
||||
set => LastServerPosTime = value;
|
||||
}
|
||||
|
||||
System.Numerics.Vector3 AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncPosition
|
||||
{
|
||||
get => LastShadowSyncPos;
|
||||
set => LastShadowSyncPos = value;
|
||||
}
|
||||
|
||||
System.Numerics.Quaternion AcDream.App.World.ILiveEntityRemotePlacementRuntime.LastShadowSyncOrientation
|
||||
{
|
||||
get => LastShadowSyncOrientation;
|
||||
set => LastShadowSyncOrientation = value;
|
||||
}
|
||||
|
||||
bool AcDream.App.World.ILiveEntityRemotePlacementRuntime.Airborne
|
||||
{
|
||||
get => Airborne;
|
||||
set => Airborne = value;
|
||||
}
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.HitGround() =>
|
||||
Movement.HitGround();
|
||||
|
||||
void AcDream.App.World.ILiveEntityRemotePlacementRuntime.LeaveGround() =>
|
||||
Motion.LeaveGround();
|
||||
|
||||
/// <summary>
|
||||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||||
/// non-trivial +Z velocity; cleared when the next UpdatePosition
|
||||
/// snaps to a new ground location. While true, the per-tick
|
||||
/// remote update SKIPS the "force OnWalkable + apply_current_movement"
|
||||
/// step that would otherwise stomp the body's Z velocity each
|
||||
/// frame, AND enables gravity so the parabolic arc actually plays
|
||||
/// out between server snaps.
|
||||
/// </summary>
|
||||
public bool Airborne;
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote position-waypoint queue + catch-up math (retail's
|
||||
/// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset).
|
||||
/// Drives per-tick body translation for grounded player remotes
|
||||
/// via <see cref="Position"/>.
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
|
||||
new AcDream.Core.Physics.InterpolationManager();
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame combiner for animation root motion + InterpolationManager
|
||||
/// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30:
|
||||
/// queue catch-up REPLACES anim when active; anim stands when queue
|
||||
/// is idle.
|
||||
/// </summary>
|
||||
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
|
||||
new AcDream.Core.Physics.RemoteMotionCombiner();
|
||||
|
||||
/// <summary>Reusable complete delta frame shared by interpolation,
|
||||
/// Sticky/Constraint, and the final body composition.</summary>
|
||||
public AcDream.Core.Physics.Motion.MotionDeltaFrame PositionManagerDeltaScratch { get; } =
|
||||
new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||||
/// previous UpdatePosition's world position + timestamp. The per-tick
|
||||
/// path compares the server's broadcast pace with the literal root
|
||||
/// displacement emitted by <c>CSequence::update</c>. The old inferred
|
||||
/// walk/run velocity was the source of the periodic overshoot this
|
||||
/// diagnostic was introduced to find.
|
||||
/// </summary>
|
||||
public System.Numerics.Vector3 PrevServerPos;
|
||||
public double PrevServerPosTime;
|
||||
public double LastOmegaDiagLogTime;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic-only: maximum scaled CSequence root-motion speed
|
||||
/// observed since the last UpdatePosition arrival. The next UP
|
||||
/// compares it with the server's observed pace. Reset on each UP.
|
||||
/// </summary>
|
||||
public float MaxRootMotionSpeedSinceLastUP;
|
||||
|
||||
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
|
||||
{
|
||||
Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody
|
||||
{
|
||||
// Remotes don't simulate gravity — server owns Z. Force
|
||||
// Contact + OnWalkable + Active so apply_current_movement
|
||||
// writes velocity through every tick (the gate in
|
||||
// MotionInterpreter.apply_current_movement is
|
||||
// PhysicsObj.OnWalkable).
|
||||
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
|
||||
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active,
|
||||
// R4-V5 door-swing fix: a RemoteMotion exists only for a
|
||||
// WORLD entity — retail's physics_obj->cell is non-null the
|
||||
// moment the object is placed. Without this, the interp's
|
||||
// detached-object guard stripped every dispatched
|
||||
// transition link (see PhysicsBody.InWorld).
|
||||
InWorld = true,
|
||||
};
|
||||
// R5-V5: the interp is owned by the MovementManager facade
|
||||
// (retail CPhysicsObj::movement_manager -> motion_interpreter).
|
||||
// acdream constructs it eagerly here — retail's lazy-create
|
||||
// window is never observable (see MovementManager's class doc).
|
||||
Movement = new AcDream.Core.Physics.Motion.MovementManager(
|
||||
new AcDream.Core.Physics.MotionInterpreter(Body)
|
||||
{
|
||||
// R4-V5 #160 fix: retail remotes carry a weenie whose
|
||||
// InqRunRate FAILS, landing apply_run_to_command on
|
||||
// my_run_rate (the M13 wire feed). A null weenie took the
|
||||
// degenerate 1.0 branch — observer-side run movetos played
|
||||
// and moved in slow motion. See RemoteWeenie.
|
||||
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
|
||||
});
|
||||
}
|
||||
|
||||
public void BindCanonicalRuntime(
|
||||
Func<AcDream.Core.Physics.Motion.IPhysicsObjHost?> readPhysicsHost,
|
||||
Func<uint> readCell,
|
||||
Action<uint> writeCell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(readPhysicsHost);
|
||||
ArgumentNullException.ThrowIfNull(readCell);
|
||||
ArgumentNullException.ThrowIfNull(writeCell);
|
||||
if (_physicsHostReader is not null
|
||||
|| _canonicalCellReader is not null
|
||||
|| _canonicalCellWriter is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The remote canonical runtime context is already bound.");
|
||||
}
|
||||
|
||||
// All validation precedes publication. Nothing below can throw, so a
|
||||
// rejected context never leaves a half-bound reusable component.
|
||||
_physicsHostReader = readPhysicsHost;
|
||||
_canonicalCellReader = readCell;
|
||||
_canonicalCellWriter = writeCell;
|
||||
}
|
||||
|
||||
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(read);
|
||||
ArgumentNullException.ThrowIfNull(write);
|
||||
if (_canonicalCellReader is not null || _canonicalCellWriter is not null)
|
||||
throw new InvalidOperationException("The remote canonical cell source is already bound.");
|
||||
_canonicalCellReader = read;
|
||||
_canonicalCellWriter = write;
|
||||
}
|
||||
|
||||
public void MarkFullPhysicsHostBound()
|
||||
{
|
||||
if (_physicsHostReader is null)
|
||||
throw new InvalidOperationException("The canonical physics-host source is not bound.");
|
||||
_fullPhysicsHostBound = true;
|
||||
}
|
||||
}
|
||||
27
src/AcDream.App/Physics/RemoteMovementObservationTracker.cs
Normal file
27
src/AcDream.App/Physics/RemoteMovementObservationTracker.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Session-scoped observation timestamps used to infer when a remote
|
||||
/// locomotion cycle has stopped. Canonical live identity remains in
|
||||
/// <c>LiveEntityRuntime</c>; this owner contains only derived observer state.
|
||||
/// </summary>
|
||||
internal sealed class RemoteMovementObservationTracker
|
||||
{
|
||||
private readonly Dictionary<uint, (Vector3 Pos, DateTime Time)> _lastMove = new();
|
||||
|
||||
internal (Vector3 Pos, DateTime Time) this[uint serverGuid]
|
||||
{
|
||||
set => _lastMove[serverGuid] = value;
|
||||
}
|
||||
|
||||
internal bool TryGetValue(
|
||||
uint serverGuid,
|
||||
out (Vector3 Pos, DateTime Time) observation) =>
|
||||
_lastMove.TryGetValue(serverGuid, out observation);
|
||||
|
||||
internal bool Remove(uint serverGuid) => _lastMove.Remove(serverGuid);
|
||||
|
||||
internal void Clear() => _lastMove.Clear();
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
|
||||
using RemoteMotion = AcDream.App.Physics.RemoteMotion;
|
||||
using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
|
|
@ -24,11 +24,10 @@ namespace AcDream.App.Physics;
|
|||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity animation-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
|
||||
/// incl. the local player's own cylinder — kept on GameWindow) and
|
||||
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
|
||||
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
|
||||
/// <para>Shared policy arrives through focused Physics delegates:
|
||||
/// <c>GetSetupCylinder</c> is owned by <c>LiveEntityMotionRuntimeController</c>,
|
||||
/// while <c>ApplyServerControlledVelocityCycle</c> is stateless policy also
|
||||
/// consumed by the UpdatePosition route. <c>SyncRemoteShadowToBody</c>
|
||||
/// (remote-physics-specific) moved here and is called back from the UP-branch
|
||||
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
|
||||
/// callers and moved here outright.</para>
|
||||
|
|
@ -40,13 +39,13 @@ internal sealed class RemotePhysicsUpdater
|
|||
|
||||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
|
||||
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||
private readonly System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||
private readonly List<AcDream.App.World.LiveEntityRecord> _spatialRemoteSnapshot = new();
|
||||
|
||||
internal RemotePhysicsUpdater(
|
||||
AcDream.Core.Physics.PhysicsEngine physicsEngine,
|
||||
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||
System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||
{
|
||||
_physicsEngine = physicsEngine;
|
||||
_getSetupCylinder = getSetupCylinder;
|
||||
|
|
@ -58,7 +57,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
|
||||
/// <summary>
|
||||
/// Advances the retained PositionManager path for every hidden remote,
|
||||
/// including objects without an <c>AnimatedEntity</c> (missiles commonly
|
||||
/// including objects without an <c>LiveEntityAnimationState</c> (missiles commonly
|
||||
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not
|
||||
/// on whether a render-animation owner exists.
|
||||
/// </summary>
|
||||
|
|
@ -93,7 +92,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
|| record.WorldEntity is not { } entity)
|
||||
continue;
|
||||
|
||||
AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity;
|
||||
LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState;
|
||||
AcDream.Core.Physics.RetailObjectActivityResult activity =
|
||||
AcDream.Core.Physics.RetailObjectActivityGate.Evaluate(
|
||||
record.ObjectClock,
|
||||
|
|
@ -165,7 +164,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// </summary>
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AnimatedEntity ae,
|
||||
LiveEntityAnimationState ae,
|
||||
float dt,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
|
|
@ -188,14 +187,14 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// Canonical ordinary-object tick. Render animation is optional: retail
|
||||
/// walks <c>CPhysics::object_maint</c>, so a live object with a
|
||||
/// MovementManager or PositionManager must continue even when it has no
|
||||
/// <c>AnimatedEntity</c> presentation component.
|
||||
/// <c>LiveEntityAnimationState</c> presentation component.
|
||||
/// </summary>
|
||||
public bool Tick(
|
||||
RemoteMotion rm,
|
||||
AcDream.Core.World.WorldEntity entity,
|
||||
float objectScale,
|
||||
AcDream.Core.Physics.AnimationSequencer? sequencer,
|
||||
AnimatedEntity? animationForVelocityCycle,
|
||||
LiveEntityAnimationState? animationForVelocityCycle,
|
||||
float dt,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame,
|
||||
int liveCenterX,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Stateless AP-80 compatibility refinement for server-controlled non-player
|
||||
/// locomotion. Kept outside network routing because the per-frame remote
|
||||
/// physics updater and accepted Position tail consume the same operation.
|
||||
/// </summary>
|
||||
internal static class RemoteServerControlledVelocityCycle
|
||||
{
|
||||
private static bool IsPlayerGuid(uint guid) =>
|
||||
(guid & 0xFF000000u) == 0x50000000u;
|
||||
public static void Apply(
|
||||
uint serverGuid,
|
||||
LiveEntityAnimationState ae,
|
||||
RemoteMotion rm,
|
||||
System.Numerics.Vector3 velocity)
|
||||
{
|
||||
if (rm.Airborne) return;
|
||||
if (ae.Sequencer is null) return;
|
||||
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
|
||||
// get_command dispatch). Velocity-estimated cycle planning would
|
||||
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
|
||||
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
|
||||
|
||||
if (IsPlayerGuid(serverGuid))
|
||||
{
|
||||
// L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
|
||||
// pace-derived cycle refinement — retail has no such mechanism
|
||||
// anywhere in its inbound pipeline (deviation map DEV-2, two
|
||||
// independent decomp dives + ACE cross-check). The #39-era
|
||||
// premise ("retail's outbound goes silent on Shift toggle") was
|
||||
// refuted at all three oracles + the S0 live capture: retail
|
||||
// sends a fresh MoveToState on HoldRun toggle while moving
|
||||
// (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
|
||||
// rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
|
||||
// and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
|
||||
// (launch-s0-wireprobe.log). The refinement layer's re-promote
|
||||
// after legitimate flags=0 Ready UMs was itself the observed
|
||||
// Ready↔Run thrash. Cycle changes for player remotes come from
|
||||
// UpdateMotion ONLY, exactly like retail; position error is the
|
||||
// InterpolationManager chase's job.
|
||||
//
|
||||
// NPC/monster remotes below keep PlanFromVelocity until S6
|
||||
// unifies all entity classes onto the CMotionInterp funnel.
|
||||
return;
|
||||
}
|
||||
|
||||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||||
// AP-80 is a position-only compatibility adaptation, not an
|
||||
// animation authority. Restrict it to Ready/Walk/Run so late death
|
||||
// position deltas cannot replace the authoritative Dead substate.
|
||||
if (!AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.CanApplyVelocityCycle(currentMotion))
|
||||
return;
|
||||
|
||||
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||||
.PlanFromVelocity(velocity);
|
||||
|
||||
uint style = ae.Sequencer.CurrentStyle != 0
|
||||
? ae.Sequencer.CurrentStyle
|
||||
: 0x8000003Du;
|
||||
|
||||
// D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
|
||||
// ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
|
||||
// SetCycle for non-player remotes (NPCs / monsters).
|
||||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
{
|
||||
System.Console.WriteLine(
|
||||
$"[UPCYCLE] guid={serverGuid:X8} "
|
||||
+ $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
|
||||
+ $"|v|={velocity.Length():F2} "
|
||||
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
|
||||
+ $"prev=0x{currentMotion:X8} "
|
||||
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
|
||||
}
|
||||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||
}
|
||||
|
||||
}
|
||||
49
src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs
Normal file
49
src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>Synchronizes a resolved remote placement in the current live origin.</summary>
|
||||
internal sealed class RemoteShadowPlacementSynchronizer
|
||||
{
|
||||
private readonly RemotePhysicsUpdater _remotePhysics;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
|
||||
public RemoteShadowPlacementSynchronizer(
|
||||
RemotePhysicsUpdater remotePhysics,
|
||||
LiveWorldOriginState origin)
|
||||
{
|
||||
_remotePhysics = remotePhysics
|
||||
?? throw new ArgumentNullException(nameof(remotePhysics));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
}
|
||||
|
||||
public void Sync(WorldEntity entity, PhysicsBody body, uint cellId) =>
|
||||
_remotePhysics.SyncRemoteShadowToBody(
|
||||
entity.Id,
|
||||
body,
|
||||
_origin.CenterX,
|
||||
_origin.CenterY,
|
||||
cellId);
|
||||
}
|
||||
|
||||
/// <summary>Bridges remote placement ownership to live presentation state.</summary>
|
||||
internal sealed class RemoteTeleportPlacementPresentation
|
||||
{
|
||||
private readonly LiveEntityPresentationController _presentation;
|
||||
|
||||
public RemoteTeleportPlacementPresentation(
|
||||
LiveEntityPresentationController presentation) =>
|
||||
_presentation = presentation
|
||||
?? throw new ArgumentNullException(nameof(presentation));
|
||||
|
||||
public void Complete(uint serverGuid, ushort generation, bool deferShadowRestore) =>
|
||||
_presentation.CompleteAuthoritativePlacement(
|
||||
serverGuid,
|
||||
generation,
|
||||
deferShadowRestore);
|
||||
|
||||
public void Begin(uint serverGuid, ushort generation) =>
|
||||
_presentation.BeginAuthoritativePlacement(serverGuid, generation);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue