merge: integrate GameWindow stabilization through checkpoint H

This commit is contained in:
Erik 2026-07-22 14:54:42 +02:00
commit c791f13853
463 changed files with 82858 additions and 16716 deletions

View file

@ -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 57 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 AH 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 IL—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).

View file

@ -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 57 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 AH 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 IL—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).

View file

@ -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 17 are
complete; Slice 8 checkpoints AG 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.

View file

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

View file

@ -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 57 complete landblock presentation and the
update/render frame graphs. Slice 8 checkpoints AH 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.
---

View file

@ -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 17 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 AG 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 IL 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.

View file

@ -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 12 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:309573309597 |
| 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 67 preserve the accepted host order as ownership-only extractions. | `src/AcDream.App/Update/UpdateFrameOrchestrator.cs` (post-live-frame teleport/camera phases); `src/AcDream.App/Rendering/PrivatePresentationRenderer.cs` (`RetainedGameplayUiFrame.Render`); `docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md`; `docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md` | Current retained UI, portal transit, reveal, camera, and connected movement traces are accepted; changing cross-subsystem host order while extracting ownership would combine a behavior change with the structural cutover. | Retained UI, teleport, and camera presentation can observe same-frame object/inbound/player state one host update earlier or later than retail at transition boundaries; a future exact host-order port must prove UI, input, reveal, and camera consequences together. | `Client::UseTime @ 0x00411C40`; `UIElementManager::UseTime`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; `CPhysics::UseTime @ 0x00509950`; retire only with a focused host-order port and connected portal/camera comparison |
| TS-54 | AdminEnvirons sound values `0x65..0x7B` are diagnosed by retail enum name but do not play audio. Retail checks that the local player physics object and UI sound table exist, then calls `SoundManager::PlaySoundFromCenter(Sound_UI_*, table)` for Roar through Thunder6. | `src/AcDream.App/World/WorldEnvironmentController.cs` (`ApplyAdminEnvirons`) | The current audio owner has no typed retail UI-sound-table binding; logging preserves the inbound evidence without inventing wave DIDs or routing the sounds through positional world audio. | Server-authored ambience/thunder packets are silent in acdream while retail plays the centered UI sound. | `CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20` (`0x0055E07F..0x0055E2C7`); `SoundManager::PlaySoundFromCenter @ 0x00550950` |
| TS-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):**

View file

@ -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 17 and Slice 8 checkpoints AH 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 AG 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 AH 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 IL 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 1726 days focused work, 35 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 125153 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 912 ms CPU to 5.36.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.

View file

@ -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 AH 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 IL 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" — 🔵 (~610 weeks)
### M4 — "Live in the world" — 🟡 NEXT (~610 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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,132 @@
# GameWindow Slice 8 — Checkpoint G Runtime Settings Plan
## Objective
Replace `GameWindow`'s duplicated pre-window and `OnLoad` settings paths with
one two-phase `RuntimeSettingsController`. The controller owns persistence and
live settings state; render, window, audio, UI, and streaming objects remain
borrowed targets. This is a structural cutover, not a redesign of the settings
format or retail option behavior.
Two carried behaviors are now explicit rather than accidental. Saved FOV is
applied during startup even when developer tools are disabled; previously it
was incorrectly gated on construction of `SettingsVM`. The named-retail
`/framerate` live toggle and notice remain intact, while acdream's already
shipped cross-launch ShowFps persistence is documented as AP-121 rather than
being attributed to the retail command body.
## Frozen behavior and ordering
1. Construct the controller before `Window.Create` and load Display, Audio,
Gameplay, Chat, and the `default` Character bag exactly once.
2. Resolve the preset plus environment overrides once for the immutable startup
snapshot. `WindowOptions.VSync` comes from the pacing policy initialized with
that snapshot; `WindowOptions.Samples` comes from the same resolved quality.
3. After window/GL input, camera, DAT effects, and optional audio exist, but
before devtools or world/render factories, apply startup settings in this
order: monitor-aware pacing, resolution/fullscreen, field of view, then audio.
4. Downstream terrain, dispatcher, render-range, and streaming factories read
the same startup quality snapshot. The legacy startup stream-radius override
remains at its current composition boundary.
5. Bind one complete `RuntimeSettingsTargets` only after window, UI (when
enabled), terrain atlas, dispatcher, render-range, and streaming exist.
Binding performs no display, quality, UI-lock, or audio replay.
6. Settings-panel Save preserves its callback order: keybindings, Display,
Audio, Gameplay, Chat, Character. Each section retains its existing
persistence/error boundary.
7. Display Save persists first, applies resolution/fullscreen, publishes the
new display snapshot, then reapplies quality. A changed MSAA sample count is
logged as restart-required and is not applied to the live GL context.
8. Runtime quality reapply preserves the existing order: alpha-to-coverage,
terrain anisotropy, render-range publication, streaming reconciliation,
completion budget.
9. `/framerate`, UI lock, loot permits, and combat preferences mutate the
controller's canonical state, synchronize the optional Settings draft using
the existing draft-preservation rules, and retain their existing save-error
behavior.
10. Enter-world changes the active toon key, loads that character bag even when
devtools are disabled, and publishes it to the optional Settings view model.
Session reset restores the startup/default character context before the
later identity-reset operation restores the key to `default`.
11. Per-frame display/audio preview reads the optional Settings draft through a
typed source. Particle range and FPS visibility read the same source so an
unsaved draft still previews exactly as it does today.
12. Shutdown first withdraws the Settings view-model and runtime-target loans;
the controller never disposes the window, renderer, UI, streaming, audio, or
input objects it borrows.
## Types and ownership
- `IRuntimeSettingsStorage` is the test seam for the five settings bags.
`JsonRuntimeSettingsStorage` is the production owner of the one concrete
`SettingsStore` and exposes that store only for retained-window layout
persistence.
- `RuntimeSettingsSnapshot` is the immutable startup value.
- `IRuntimeSettingsStartupTarget` receives the one startup Display/Audio apply.
- `IRuntimeSettingsTargets` receives future display-window, quality, and UI-lock
changes. `BindRuntimeTargets` stores it without invoking it.
- `IRuntimeSettingsPreviewSource` exposes current Display/Audio preview values
and whether a live draft exists.
- `RuntimeSettingsController` owns persistence, current values, active toon,
resolved quality, and the optional `SettingsVM` binding. It also implements
the combat settings source; no second gameplay-state mirror remains.
- `SilkRuntimeDisplayWindowTarget`, `RuntimeSettingsStartupTargets`, and
`RuntimeSettingsTargets` are App-layer adapters over borrowed runtime objects.
## Automated gates
- Construction loads every bag once and populates one immutable snapshot.
- Startup apply is ordered, exactly once, and independent of GL in controller
tests.
- Runtime target binding produces zero calls; unbinding prevents future target
calls while state and persistence still advance.
- Display Save ordering, quality transition, and restart-required MSAA logging.
- Quality target receives one complete resolved value per reapply.
- Draft preview/cancel, external FPS mutation, UI lock, loot permits, and combat
option synchronization.
- Toon activate/load/reset/save behavior with and without a Settings view model.
- Save failures preserve each established state/target boundary.
- Source-boundary test proves `GameWindow` no longer constructs or loads a
`SettingsStore`, owns persisted settings fields, or contains quality/display
feature bodies.
- Focused App tests, full App tests, Release solution build, and full Release
suite.
## Review and commit gate
After the focused tests pass, the existing retail-conformance,
architecture/integration, and adversarial reviewers inspect the complete diff
in parallel. Confirmed findings are corrected and the same reviewers repeat
until clean. Documentation, roadmap, issues, memory, `CLAUDE.md`, and
`AGENTS.md` are reconciled in the same bisectable Checkpoint G commit.
## Implementation result
- `RuntimeSettingsController` is constructed before `Window.Create`; all five
bags load once and `WindowOptions` consumes its immutable startup snapshot.
- Startup substeps commit independently, so an audio failure retries audio
without replaying already successful pacing/display/FOV work.
- Late target binding is inert and reversible; expected-instance SettingsVM
withdrawal cannot detach a replacement binding.
- The concrete quality adapter preserves all five application steps and the
unchanged MSAA restart boundary.
- Combat changes merge their three fields into a live Gameplay draft rather
than overwriting unrelated unsaved fields; successful external changes also
advance the SettingsVM persisted baseline so Cancel cannot resurrect stale
command state.
- UI-lock target, persistence, and baseline publication form one explicit
convergence state. A transient failure leaves the same requested value
retryable instead of letting the equality fast-path strand unfinished work.
- Enter/reset/toon changes load and publish Character state independently of
developer-tool availability.
- `GameWindow` no longer constructs or loads `SettingsStore`, owns persisted
settings mirrors, or contains display/quality/settings feature bodies.
- Candidate metrics: 3,663 raw lines / 162 fields / 37 methods, down 394 lines /
36 fields / 17 methods from Checkpoint F and 12,060 lines (76.7%) from the
campaign baseline.
- Thirty-five focused App settings/boundary tests, all 43 `SettingsVMTests`, the
UI Abstractions Release suite (534 pass), and the App Release suite (3,183
pass / 3 intentional skips) pass. All three independent corrected-diff
reviews are clean. The production App Release build has zero warnings/errors;
the solution Release build retains the 17 existing #228 test-project warnings;
and the complete Release suite passes 7,553 tests / 5 intentional skips.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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 AH. `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 AE | shell freeze, native/session/environment composition, reversible pointer/input/framebuffer ownership | 4,266 / 194 / 65 |
| 8 F | sole gameplay action route, typed commands, retained-root callback lifetime | 4,057 / 198 / 54 |
| 8 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 1833 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 IL 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 17 already established a
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.
The reviewed AL 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.

View file

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

View file

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

View 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);
}
}

View 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);
}
}

View file

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

View 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));
}
}

View file

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

View file

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

View 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;
}
}

View file

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

View file

@ -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);
}

View 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;
}
}

View 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));
}
}

View 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;
}

View file

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

View file

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

View file

@ -0,0 +1,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();
}
}

View 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;
}

View 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);
}
}
}

View 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);
}

View file

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

View file

@ -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);
}
}

View 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;
}
}

View 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());
}

View 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()
{
}
}

View file

@ -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++;
}
}

View file

@ -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;
}
}

View 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;
}
}

View file

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

View file

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

View file

@ -1,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;
}

View file

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

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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;
}
}

View file

@ -0,0 +1,11 @@
namespace AcDream.App.Net;
internal interface ILiveInWorldSource
{
bool IsInWorld { get; }
}
internal interface ILiveWorldSessionSource
{
AcDream.Core.Net.WorldSession? CurrentSession { get; }
}

View 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));
}

View 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}",
};
}

View file

@ -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));
}
}

View 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);
}
}
}
}

View 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);
}
}

View 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;
}
}

View 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}).");
}
}

View 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;
}

View 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;
}
}

View file

@ -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);
}

View 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);
}
}

View file

@ -0,0 +1,6 @@
namespace AcDream.App.Physics;
internal interface ILocalPlayerLandblockSource
{
uint? LastKnownLandblockId { get; }
}

View 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);
}
}

View 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;
}
}

View 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;
}

View 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-&gt;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>
}

File diff suppressed because it is too large Load diff

View 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;
}
}

View 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();
}
}

View 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;
}

View 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();
}

View file

@ -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;
}

View 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 &gt; ε 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;
}
}

View 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();
}

View file

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

View file

@ -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);
}
}

View 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