From e0f96a55bf1fa122c3e03797ca2e9811966de2d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 03:57:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20C4=20route=203=20=E2=80=94=20po?= =?UTF-8?q?rtal=20placement=20authority=20(local=20player)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 59 ++ .../retail-divergence-register.md | 9 +- docs/plans/2026-08-02-placement-cutover.md | 28 + ...2026-07-16-portal-completion-pseudocode.md | 45 +- ...4-c4-route-3-architecture-review-round2.md | 411 +++++++++++ ...26-08-04-c4-route-3-architecture-review.md | 550 ++++++++++++++ ...6-08-04-c4-route-3-retail-review-round2.md | 434 +++++++++++ .../2026-08-04-c4-route-3-retail-review.md | 515 +++++++++++++ .../Composition/SessionPlayerComposition.cs | 23 +- .../LiveEntityNetworkUpdateController.cs | 23 +- .../LocalPlayerTeleportController.cs | 383 ++++++++-- .../World/RuntimePlacementPresentationSink.cs | 17 +- .../Physics/PhysicsDiagnostics.cs | 57 ++ .../HeadlessRuntimePlacementProjectionSink.cs | 28 +- .../Hosting/HeadlessSessionHost.cs | 40 +- .../Hosting/HeadlessSessionWorldProjection.cs | 278 +++++-- .../Gameplay/PlayerMovementController.cs | 139 ++++ .../RuntimeAcceptedPositionDriveController.cs | 641 +++++++++++++++- .../RuntimeLiveEntitySessionController.cs | 112 ++- .../LocalPlayerTeleportControllerTests.cs | 691 ++++++++++++++++-- .../RuntimePlacementPresentationSinkTests.cs | 23 +- .../HeadlessSessionHostTests.cs | 405 +++++++++- ...imeAcceptedPositionDriveControllerTests.cs | 590 ++++++++++++++- ...RuntimeLiveEntitySessionControllerTests.cs | 3 +- 24 files changed, 5261 insertions(+), 243 deletions(-) create mode 100644 docs/research/2026-08-04-c4-route-3-architecture-review-round2.md create mode 100644 docs/research/2026-08-04-c4-route-3-architecture-review.md create mode 100644 docs/research/2026-08-04-c4-route-3-retail-review-round2.md create mode 100644 docs/research/2026-08-04-c4-route-3-retail-review.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0afa4b9b..9c0411a9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,65 @@ 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. +## #318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering + +**Status:** OPEN +**Severity:** LOW (does not block round-3 acceptance per both reviewers; carried +into C5) +**Filed:** 2026-08-05, C4 route 3 round-3 review (retail B5/A5, architecture +B5), carried per both reviewers' explicit conditions +**Component:** Runtime / portal placement / local-player presentation + +**Description:** The retail review's round-1 §3.4 premise — that +`TryApplyRuntimePlacementPlace` does not write pose/rotation/`ParentCellId` or +rebucket — was WRONG; round 3 verified it DOES. That closed the original +blocking concern, but three narrower gaps remain and both reviewers agreed +they must be tracked rather than silently dropped: + +1. No end-to-end composition test exercises the full portal-arrival → + canonical commit → presentation-suffix → `PhysicsEngine.ShadowObjects` + chain for the LOCAL player specifically (existing tests cover pieces — + the canonical commit, the presentation sink's `TryApply`, the drive + controller — but not the full composed path with a real + `RuntimePlacementPresentationSink` wired to a real `PhysicsEngine`). +2. No test asserts the local-player collision SHADOW lands at the + destination. The discriminating assertion for that future test: + `PhysicsEngine.ShadowObjects` must hold a row at the destination cell/ + position, not just `LocalPlayerShadowState`'s internal dedup cache — see + the register row (AP-131 amendment, filed alongside this issue) for the + asymmetry this exposes: `LocalPlayerShadowState.Set` updates the dedup + cache without publishing to `ShadowObjects`, self-healing only on the + local player's first subsequent movement tick. +3. No test proves T8's ordering — that the canonical commit's writes + (pose/rotation/`ParentCellId`/rebucket) precede the presentation suffix's + OWN redundant writes to the same fields, rather than racing or reversing. + +**Root cause / status:** Not a defect — a coverage gap. The underlying +mechanism (`RuntimePlacementPresentationSink.TryApply` → +`LiveEntityRuntime.TryApplyRuntimePlacementProjection` → +`TryPublishPlace` → `LocalPlayerShadowState.Set`) is correct by code reading +and by the individual unit tests that DO exist; what's missing is the +COMPOSED, end-to-end proof plus the specific shadow-registry assertion. + +**Files:** `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` +(`TryPublishPlace`, `LocalPlayerShadowState.Set` call); `src/AcDream.App/Physics/LocalPlayerShadowState.cs`; +`src/AcDream.Core/Physics/PhysicsEngine.cs` (`ShadowObjects`); +`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` +(`ReconcileAndAcknowledgePortal`, the T8 probe log). + +**Research:** `docs/research/2026-08-04-c4-route-3-contract.md` §3.4; +`docs/research/2026-08-04-c4-route-3-retail-review-round2.md` §D (B5/A5); +`docs/research/2026-08-04-c4-route-3-architecture-review-round2.md` B5. + +**Acceptance:** A composition test drives a real portal arrival through the +canonical drive controller and the real `RuntimePlacementPresentationSink` +against a real `PhysicsEngine`, then asserts `PhysicsEngine.ShadowObjects` +holds the local player at the destination position/cell (not merely +`LocalPlayerShadowState`'s cache) and that the write ordering matches T8 (a +probe or log-order assertion). Do not score the existing connected/manual +gate as covering this — it exercises the live path but does not assert the +shadow registry specifically. + ## #317 — `TryCommitAuthoritativeVelocity`'s call site has no established retail basis **Status:** OPEN diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c2df6bd1..9f41ffa7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 49 active rows (AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-42 refreshed same round — its cited App-side login resolve split was deleted by the C3c flip, the split survives only on the unflipped remote-teleport/headless portal-resync paths; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 48 active rows (AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -116,7 +116,7 @@ readiness/requeue adaptation. See | AD-52 | **Filed at Campaign N slice N6 (2026-07-29).** The inbound fragment assembler evicts incomplete partial messages 60 s after their last ACCEPTED fragment (swept on retail's 5 s flush cadence from `ReliableTransport.Sweep`) and remembers the last 64 completed multi-fragment sequences in a ring so a late duplicate fragment of an already-completed message drops instead of allocating a fresh partial that can never complete. Retail's prune target and horizon differ: its 5 s-TTL `FlushTimedOutEphInfo` table holds ephemeral-blob ORDERING stamps (the AD-49 deferral), not partial payloads. | `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` (`SweepExpired`, `PartialTtlSeconds`, `CompletedRingSize`); cadence in `src/AcDream.Core.Net/Transport/ReliableTransport.cs` (`AssemblerSweepSeconds`) | N4's RejectRetransmit abandonment made an unrecoverable partial a REACHABLE permanent state: ACE pruned a fragment-bearing packet from its 120 s S2C cache and told us to stop asking, so that blob can never complete — without a TTL it leaks for the session's lifetime. 60 s is ≫ every recovery horizon (0.6 s NAK cadence, ACE's 2 s ack, the 120 s cache) and the stamp refreshes on every accepted fragment (retail's own re-stamp rule, `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00`), so only a server-abandoned partial can age out — a merely-slow one cannot. The ring is bounded (64 × 4 B) and its only false negative (a duplicate arriving after 64 later completions) degrades to the pre-N6 behavior, now reclaimed by the TTL. | If ACE ever legitimately re-served a fragment of a completed message under a REUSED fragment sequence within the ring window, it would be dropped — but fragment sequences are strictly monotonic per session (ACE SessionConnectionData.FragmentSequence), so reuse cannot happen inside one connection. An evicted partial whose fragments later straggle in re-partials and re-evicts — bounded churn, no corruption. | `Indicator::FlushTimedOutEphInfo @ 0x0054A3D0` (the 5.0 s flush gate at 0x0054A3DC); `ArrivedEphInfo::fTimedOut @ 0x0054AE30` (per-entry 5.0 s TTL); `ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00` (re-stamp on update); retail has no partial-payload TTL — its blob layer trusts its own NAK persistence, which N4's ACE-mandated abandonment (`SharedNet::HandleEmptyAck @ 0x005448F0`) breaks | | 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 | **NARROWED 2026-07-31 (placement Slice 4B2 checkpoint 2).** Runtime now owns exact lost-cell residence, adjusted frame retention, 25-second root/direct-child lifetime, generation-scoped wake, revisioned Withdraw/Place receipts, one public generation-gated observe/retry/exact-ack seam, and the retail collision-table/report-result state needed by SetPosition. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production authoritative placement still routes through the legacy recoverable outdoor demote and outdoor-restore `max(terrainZ, z)` lift until the remaining authored-mover, rebucketing, prefix-quiescence, body-publication, and route-cutover prerequisites land atomically. | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; legacy route in `src/AcDream.Core/Physics/PhysicsEngine.cs` | The canonical owners remain dormant and separately gated, so this ownership checkpoint cannot partially change the accepted production world. | Until 4B2, a production gap can still commit an outdoor approximation inside/under a building or lift a legitimate below-heightmap restore instead of entering the now-available Runtime lost-cell owner. | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945; `CPhysicsObj::handle_all_collisions` 0x00514780 | -| 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 required 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 unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `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 or continue simulating an old/partial 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, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `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 required 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 unhydratable-placement path. `RuntimeWorldTransitState` owns the shared reveal generation, accepted readiness, transit correlation, and exact generation/cell-scoped host-acknowledgement suffix. `WorldRevealCoordinator` is a graphical adapter holding only App resource receipts; normalized Runtime checkpoints observe ownership without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. **C4 route 3 refinement (2026-08-04):** retail places the local player IMMEDIATELY on the accepted destination Position (`SmartBox::TeleportPlayer` @0x00453910) and blocks SIMULATION on DAT prefetch (`CellManager::blocking_for_cells`; `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus`) behind the portal viewport; acdream defers the PLACEMENT itself to this reveal-ready Place edge, executed by the canonical `RuntimeAcceptedPositionDriveController` portal arm (`TryExecuteAcceptedPortalArrival`). Two load-bearing notes from that route: (1) every accepted local Apply — including the portal destination Position itself — still writes the raw wire pose onto the local player's `WorldEntity` via the ordinary generic-remote-render-pose path while portal space covers the viewport (`LiveEntityNetworkUpdateController.cs`, `OwnsSteadyState` false for the local player's null route); the committed Place receipt's presentation suffix overwrites it with the resolved pose — tolerated, not suppressed, since suppressing it would be an unowned behaviour change on the ordinary local Apply path (AP-131/#275 territory). (2) The constraint-leash re-arm on a committed portal placement anchors at the RESOLVED post-placement body position (`PlayerMovementController.CommitCanonicalTeleportFrame` → `RearmConstraintLeashAtCurrentPosition`), where retail's `ConstrainTo` @0x0045418A anchors at the received WIRE destination; the two differ by at most the placement adjustment (ring search/floor snap) and the anchor is write-only downstream, so the delta is not user-observable — switching to the wire-destination anchor is a deliberately deferred decision, not adopted here. **B4 round-3 review refinement (2026-08-05):** the wait cue's trigger predicate (`LocalPlayerTeleportController.Tick`'s `placementReady = dataReady && TryAdvancePortalCommit(sequence)`, gating the cue at `haveDestination && !placementReady`) now covers a SECOND, distinct cause beyond the original streaming/DAT-readiness gate this row described: `TryAdvancePortalCommit` returning false while a DeferredCell park is outstanding or a fresh placement attempt has not yet succeeded (B1's `TryConsumePortalCommit` gate). The cue's five-second trigger and centered-tunnel behavior are unchanged; only the SET of conditions that can hold it open grew from "world data not ready" to "world data not ready OR canonical placement not yet committed" — a slow-publishing destination-landblock collision generation now presents identically to a slow asset stream, which is the correct retail-faithful degradation (both are `blocking_for_cells` causes retail itself does not distinguish), but is worth naming here since a future debugging session seeing the cue must not assume streaming is the only possible cause. | `src/AcDream.Runtime/World/RuntimeWorldTransitState.cs`; `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `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 or continue simulating an old/partial 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, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | 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~~ | **RETIRED 2026-07-31 (placement/streaming Slice 3B).** Cell/cache/topology/building/static-shadow publication plus every retained non-suspended owner touching or withdrawn from the prefix is one Runtime-owned collision generation. Retained includes dynamics and adjacent-root statics; only target-root statics are superseded by the authored replacement. App and Headless build one shared off-side `CollisionWorldState` through one-work-unit preparation/capture/seal cursors. Admission captures the active root in O(1); a stable landblock/owner slot suffix materializes non-target leaves incrementally, so resident-world size cannot become a synchronous clone spike. Reusable per-prefix owner slots and one Runtime-scoped versioned journal replace event-time exact-copy fanout: repeated live mutations coalesce by owner, every draft reconciles only that owner's latest exact state one owner per seal call, discovered relevant owners receive scoped exact updates, and visited unrelated owners receive only a cheap coalesced dirty notification before metered replay. Once topology sealing finishes, observed owners temporarily write through exactly until same-call activation; the finite pre-seal queue therefore drains even under continuous multi-owner movement. New drafts start at their captured journal suffix; old slots are superseded rather than reused behind live cursors and compact through the same meter. Unrelated churn therefore never restarts or starves target capture/sealing. Deterministically ordered concurrent preparations receive committed—not merely sealed—peer deltas and rebase one cache, graph, landblock, or owner leaf per seal step; cancellation therefore cannot leak unpublished topology. Demotion/withdrawal cancels a matching queued or active rebase, suppresses the prefix in unfinished source scans, and retires one owner/cache/graph/outdoor leaf per seal call. The complete previous generation remains queryable until one zero-managed-byte volatile root transfer in the same update-thread call as final reconciliation; that preserves PhysicsDataCache, CellGraph, PhysicsEngine, and ShadowObjectRegistry facade identity, revokes staging, and requires no quiet frame. A stale admission or staging failure disposes only that private generation and cannot withdraw the active world or invalidate a newer admission. Authored same-ID target statics, live-current-cell changes, owner departure/reuse, newly relevant seam-crossing statics, and teardown remain coherent across drafts; empty per-prefix owner containers are reclaimed without invalidating captured seal cursors. The commit clears repaired withdrawal markers before its single notification/readiness acknowledgement, so no optional hydration callback can omit reflood and no observer sees mixed old/new cells. | `src/AcDream.Runtime/Physics/RuntimePhysicsState.cs` (`PrepareCollisionGeneration`, `AdvanceCollisionGenerationPreparation`, `AdvanceCollisionGenerationSeal`, `CommitCollisionGeneration`); `src/AcDream.Core/Physics/CollisionWorldState.cs`; `PhysicsDataCache.cs`; `PhysicsEngine.cs`; `ShadowObjectRegistry.cs`; `src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs`; `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimePhysicsStateTests.cs`; `tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs`; `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs` | — | — | `CObjCell::init_objects` → `CPhysicsObj::recalc_cross_cells`, 0x0052b420 / 0x00515a30; `CPhysicsObj::SetPositionInternal` shadow replacement tail 0x00515330 | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | **2026-08-04: file:line corrected** — the mechanism now lives in `src/AcDream.Core/Physics/RemoteMotionCombiner.cs` (`ComposeOffset` ~:65-72 for the interpolation-active boundary projection, the queue-empty fallback ~:163-168); the row's meaning is unchanged, the class was renamed/moved from the stale `PositionManager.cs:47` citation (see the class's own doc comment: "Renamed R5 (was PositionManager)") | 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; it also cannot see building/EnvCell geometry at all (terrain-only sample), so a remote landing on a house roof gets no slope response from this path regardless of `OnWalkable` — a contributing factor in the 2026-08-04 Bug B roof-plant observation (see `docs/ISSUES.md` #32) | `CTransition::adjust_offset` pc:272296-272346 | @@ -146,7 +146,6 @@ readiness/requeue adaptation. See | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/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 | **Refreshed 2026-08-02 (C3c review round 1). Citation corrected 2026-08-04 (C4 route 4b-3): the remote-teleport controller half is deleted with that slice — the standalone `RemoteTeleportController` no longer exists; the teleport arm now runs through the same canonical Runtime SetPosition transaction the far arm uses, and this row's split survives only on the remaining unflipped path.** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `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. The surviving split path is the headless portal-route scope | A teleport arrival 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` | @@ -160,7 +159,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 100 active rows (AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 102 active rows (AP-145 filed 2026-08-05, C4 route 3 round 3 (B5, issue #318) — the local-player collision-shadow presentation write goes through a direct cache `.Set()` that bypasses the publisher's own `ShadowObjects` write, self-healing only once dedup diverges; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) 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 @@ -171,6 +170,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-141 | **Filed 2026-08-04, C4 route 5 (projectile authoritative placement); NARROWED 2026-08-04 at the round-2 delta review (B1/B2) — the far-branch clause was factually wrong for the adopted-body case and is corrected below.** Three related projectile-only shapes, all pinned by design (D-P4) rather than ported: (a) the near-`Interpolate` disposition is a NO-OP for a live missile, where retail would lazily build interpolation machinery (`InterpolateTo` @0x005163AF) for it; (b) the post-operation `ConstrainTo` @0x00454272 (`MakePositionManager` @0x00510523 then `PositionManager::ConstrainTo`) is never ARMED for a projectile — retail's single arming site has no kind test, so retail WOULD build a `PositionManager` on demand and arm a missile's leash on any nonzero `MoveOrTeleport` return; acdream never arms it on any disposition, including the adopted-body case (whose PRE-EXISTING leash the teleport/far branches now un-arm or clear queue state for, but never RE-anchor, per retail's post-operation `ConstrainTo`); (c) a null-classified or `Rejected*` accepted Position for a missile is swallowed (write nothing) rather than caught up through any remote-shaped policy. | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`ApplyAcceptedProjectilePosition`) | acdream deliberately does not construct an `EntityPhysicsHost`/`PositionManager`/`InterpolationManager` chain for a ballistic body — the route-5b split the C4 route 5 contract rejected. The context that makes this safe rather than merely convenient: ACE never sends `UpdatePosition` for a missile (`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:333-334`, `SendUpdatePosition()` commented out inside the `PhysicsState.Missile` branch at `:265`) — every half of this row is deterministic-test-gated only, never exercised against a real server. **The far branch's `StopInterpolating` skip is retail-faithful ONLY for a BARE missile** (no `RemoteMotion` — retail's own `position_manager != 0` guard @0x005163C9 skips it for a never-interpolated object, so acdream's skip is faithful by consequence there). For the ADOPTED-BODY case (`TryBind`'s shared-body branch: an ordinary remote whose Missile bit was set by a later State packet, still carrying its `RemoteMotion`), retail's guard IS satisfied and retail WOULD clear the queue — acdream now ports this (`route.StopInterpolating && record.RemoteMotion is RemoteMotion adopted → adopted.Interp.Clear()`), matching the teleport branch's equivalent `StopInterpolating` action inside `teleport_hook`. What remains divergent for the adopted case is the post-operation `ConstrainTo` re-anchor @0x00454272 — retail re-anchors an existing leash at the just-updated position on every nonzero return; acdream never arms/re-anchors it on any projectile disposition (clause (b)). | A future change that DOES give projectiles a `PositionManager` (or a headless/no-window remote-motion consumer that expects one) must re-decide this row rather than silently building the machinery ad hoc; until then, a live missile never shows an ARMED constraint leash and never catches up via the near/UnroutedCatchUp policy — both unreachable in play. An adopted-body missile's INHERITED leash (armed before it became a missile) is un-armed by the teleport hook, has its queue cleared by both teleport and far, but is never re-anchored at the new position by either — its brake accumulator (`ConstraintPosOffset`) is not reset to zero at each accepted Position the way retail's @0x00454272 re-anchor does. **Correction, round 3 (2026-08-04): the round-2 wording here — that a stale leash "would drag the body toward a stale anchor" — was wrong and is retracted.** `ConstraintManager.ConstraintPos` is write-only in both retail and the port (never read by `AdjustOffset`), and `ConstraintManager::adjust_offset` @0x00556180 only tapers or zeroes an already-composed per-tick offset while `InContact` — a leash brakes motion the interp/sticky chain already produced; it has no mechanism to move anything toward the anchor. The real residual is confined to one tick of un-reset brake accumulator, contact-gated, and it cannot move an airborne far-snapped missile at all (the clamp branch does not run while airborne). | `CPhysicsObj::MoveOrTeleport` 0x00516330 (`InterpolateTo` @0x005163AF, `IsMovingTo` @0x0050EB10 returning 0 without a `MovementManager`; far branch `StopInterpolating` @0x005163C9-@0x005163CB); `SmartBox::HandleReceivedPosition` 0x00453FD0 (`ConstrainTo` arming site @0x00454272); `CPhysicsObj::ConstrainTo` 0x00510520 (`MakePositionManager` @0x00510523); `ConstraintManager::adjust_offset` 0x00556180 (brake-only taper, write-only anchor); `WorldObject_Tick.cs:333-334`/`:265` (ACE never-sends evidence) | | AP-142 | **Filed 2026-08-04 (C4 route 7, pickup/parent/delete). AMENDED 2026-08-04 at the dual-Opus retail-conformance/architecture review round (R1/A8 MAJOR+LOW; R10 MINOR) — clause (d) added, clause (b) corrected. AMENDED AGAIN 2026-08-04 at the round-3 dual review (N1/N2/N4, B3) — clause (d)'s reasoning corrected and its risk-column scope widened; clause (e) RETIRED — the depth cap it described is deleted outright, replaced by an iterative worklist with no depth concept at all.** acdream collapses retail's `CPhysicsObj` pair — a `cell` pointer plus a separately-written `objcell_id` — into ONE canonical `RuntimeEntityRecord.FullCellId`, which is also the residency/liveness predicate acdream reads at 45+ sites. Four consequences, all intentional: (a) the removal path propagates ZERO to a subtree's children (withdrawal, delete, `EndGeneration`), where retail's `leave_cell` recursion nulls only each child's `cell` pointer and leaves a STALE non-zero `objcell_id` (`change_cell`'s removal tail @0x005133C1 never touches a child's id) — reproducing that stale-id residue would leave a child "resident" per every acdream predicate while retail's own gating field (`cell == nullptr`) says it is not; (b) retail's same-cell depth-1 per-tick `objcell_id` refresh (`SetPositionInternal` @0x0051539c-@0x005153d8, gated on the parent NOT crossing a cell) is subsumed by the value-idempotent propagation chokepoint (`RuntimeEntityDirectory.SetFullCell`'s "skip a child whose `FullCellId` already equals the target" guard) rather than ported as a separate tick loop — a same-value restamp is unobservable with one field playing both retail roles. **Correction (R10): this is a clean equivalence only on the REMOVAL side.** The skip ALSO prunes the child's whole subtree on a same-value WRITE, which retail's `enter_cell` does not do — it recurses over children unconditionally (@0x00510f03); only `leave_cell` prunes (@0x00510f5b, on `cell != 0`). Currently unreachable-by-construction (after D4 nothing writes a grandchild's cell independently of its own committed parent), but it is an asymmetry, not a proven equivalence; (c) the sustaining propagation itself: retail re-cells children when the parent crosses a cell, recursively, on EVERY `SetPositionInternal`/`change_cell` (@0x00515372/@0x00513390), not only at attach — acdream ports this as a single hook every canonical cell-write funnels through, so an attach-only write (the pre-existing shape) is deliberately NOT what shipped. **(d) retail's `enter_cell` gates its ENTIRE body — the write AND the recursion into children — on `this->part_array != 0` (@0x00510ed8); a child with a null part array receives nothing and its whole subtree is skipped. acdream's propagation has NO analogue and writes unconditionally. CORRECTED reasoning (round-3 review, N1/N2): the original draft of this clause argued acdream's `HasPartArray` means something semantically different from retail's `part_array` (a "renderer built a mesh" flag vs. "this CPhysicsObj has any part array"). That framing is WRONG — retail's `part_array` has exactly ONE assignment site, `CPhysicsObj::makeAnimObject` @0x0050e930 → `CPartArray::CreateSetup`, assigned @0x0050e94d, so retail's flag is ALSO a mesh-construction product; the two are near-synonyms, not different concepts. The REAL reason acdream cannot gate the canonical D1/D2 write on `HasPartArray` is LAYERING, not semantics: Slice J made the Runtime canonical layer presentation-independent by design (`docs/research/2026-07-25-slice-j1-runtime-contract-closeout.md` and the Slice J campaign generally), and `HasPartArray` is populated exclusively by App/graphical code (`EquippedChildRenderController.cs:609`, `DatLiveEntityProjectionMaterializer.cs:203`) — the canonical layer structurally cannot depend on a flag only the presentation layer ever writes, headless or not. CORRECTED scope (round-3 review): this is NOT headless-only. `PrepareAndTryRealize` calls `CommitAcceptedParentCellless` (hence D1's re-cell) BEFORE `TryRealize` sets `HasPartArray = true` at `:609` — so at the exact moment D1 runs, `child.HasPartArray` is FALSE in the GRAPHICAL host too, and gating on it would break attach there as well, not just headless. Retail has no equivalent window at all: `part_array` is assigned once at construction and `enter_cell`'s guard reads that same, already-settled field.** The guard is deliberately NOT reproduced at the canonical layer. **(e) RETIRED 2026-08-04 (round-3 review, N4/B3 — both reviews independently found the same defect).** Previously: recursion depth capped at 64 levels as hostile/buggy-server hardening. The cap's actual failure mode was worse than what it guarded against: a subtree beyond the cap was left at its PRIOR — on the withdraw path, STALE NONZERO — cell PERMANENTLY, logged only under a probe flag nobody runs by default. On the withdraw path that is the #184 shape verbatim: an entity every acdream residency predicate calls resident that retail (and clause (a) above) says is not. Shipping that inside the slice whose headline is fixing exactly this class was unacceptable. Retired by deleting the cap outright and replacing the recursion with an iterative worklist (`RuntimeEntityDirectory._propagationWorklist`), which has no stack-frame-bounded depth at all — the only limit is the number of committed relations actually in the system, matching retail's own genuinely unbounded recursion with no acdream-only cap and therefore no register row for one. | `src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs` (`SetFullCell`, `PropagateFullCellToChildren`, `RefreshSnapshot`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitAcceptedParentCellless`'s D1 half, `WithdrawCommittedChildrenToCellless`); `src/AcDream.Runtime/Entities/ParentAttachmentState.cs` (`TryGetCommittedParent`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`) | Reproducing retail's pointer/id split would require a second field acdream's 45+ liveness call sites would then have to be individually audited for which half they mean — the single-field model is a stated, load-bearing simplification, not an oversight; see `docs/research/2026-08-04-retail-parent-cell-propagation.md` and `docs/research/2026-08-04-c4-route-7-contract.md` D2/D3/D9. | A future consumer that expects retail's exact stale-`objcell_id`-under-a-null-`cell` shape (none identified) would see a fully cell-less child instead. (d)'s risk: acdream celling a child retail would leave nowhere — none identified in play against a well-behaved ACE, since a server-authored equip always names a real, DAT-resolvable Setup, and the graphical host's own brief pre-`TryRealize` window is bridged by D1 running inside the same synchronous transaction as the rest of the attach commit, not by `HasPartArray` being true. | `CPhysicsObj::change_cell` 0x00513390 (@0x005133C1 removal tail); `CPhysicsObj::enter_cell` 0x00510ed0 (@0x00510ed8 the `part_array` guard); `CPhysicsObj::leave_cell` 0x00510f50; `CPhysicsObj::SetPositionInternal` 0x00515330 (@0x0051536d branch, @0x0051539c-@0x005153d8 same-cell loop, @0x00515372 cell-change branch); `CPhysicsObj::makeAnimObject` 0x0050e930 (`CPartArray::CreateSetup` assignment @0x0050e94d) | | AP-143 | **Filed 2026-08-04 (C4 route 7 D5, headless parent-realize drive). AMENDED 2026-08-04 at the retail-conformance review round (R7 MINOR) — this row originally described only ONE of the three checks the drive skips. Line citations corrected at the round-3 review (N3).** The graphical `EquippedChildRenderController.ValidateParentProjection` performs three retail-anchored checks before accepting a parent-attach request: (1) self-parenting rejection (`relation.ParentGuid == relation.ChildGuid`, `:915-916`); (2) the parent must have a constructed part array (`parent.HasPartArray`, `:920` — the closest acdream analogue to retail's `part_array != 0` guard, AP-142 clause d); (3) `Setup.HoldingLocations` validates the specific holding location (`CSetup::GetHoldingLocation` @0x0050F896, via `PartArray::add_child`). `AcDream.Headless`/`AcDream.Runtime`'s direct-host parent-realize drive (`RuntimeLiveEntitySessionController.ResolveAndCommitChildAttachment`) performs NONE of the three — it commits on the POSITION_TS gate acceptance and relation resolution alone. (1) is inert by construction: D1's re-cell gate reads `parent.FullCellId == 0` (the child was just zeroed by the cell-less edge before D1 runs), and D2's skip-on-equal terminates the resulting one-node cycle — a self-parent headless commits the relation but never observably re-cells through it. (2) has no headless analogue at all (see AP-142 clause d — `HasPartArray` is populated only by the graphical mesh pipeline, never headless, for ANY entity). (3) has no prepared-content surface (repo-wide grep confirms nothing under `src/AcDream.Content`/`AcDream.Bake` carries `Setup.HoldingLocations`). | `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | Precedent: the content-less host already accepts reduced fidelity elsewhere (`RuntimeLiveEntitySessionController:108-117`'s documented content-less registration). A server-sent self-parent, part-array-less parent, or invalid holding location is unreachable against a well-behaved ACE (ACE only emits `ParentEvent` for a location its own `Player_Inventory`/wield validation already accepted), so this is a defense-in-depth gap, not a live-play one. | A malicious or buggy server could attach a child headless where retail and the graphical host would both reject it — inert against ACE today for all three. Retiring (3) means extending the prepared-content bake format with `Setup.HoldingLocations`, deliberately NOT done in this slice (route 7 contract §4 D5); (2) has no retiring action available until acdream's canonical layer gains its own construction-time part-array concept (a larger architectural question, out of scope here). | `PartArray::add_child` (`CSetup::GetHoldingLocation` 0x0050F896); `CPhysicsObj::enter_cell` 0x00510ed8 (the `part_array` guard); `EquippedChildRenderController.ValidateParentProjection` (graphical port, all three checks) | +| AP-144 | **Filed 2026-08-05 (C4 route 3, round-3 review R7). Register discipline finding, not an implementer's disposition** — CLAUDE.md's register rule binds regardless of whether the gap has a live symptom yet. `RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal`'s teleport-arrival movement-event send gates on `!RuntimeCharacterState.UsePositionFromServer` — retail's `CommandInterpreter::UsePositionFromServer` @0x006B3B40, which is `autonomy_level != 2`. But the retail function that ACTUALLY gates this send is a different one: `CommandInterpreter::SendMovementEvent` @0x006B4680 (the `PlayerTeleported` tail-jump), which gates on `autonomy_level != 0` — the LOOSER test, excluding only level 0, satisfied by BOTH level 1 and level 2. acdream's gate reuses the STRICTER `UsePositionFromServer` test (excluding two of the three levels, 0 AND 1), built from the wrong retail function, so it sends only at level 2 and wrongly suppresses at level 1. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`ReconcileAndAcknowledgePortal`, the `!_usePositionFromServer()` guard around `TrySendMovement`); `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`UsePositionFromServer`, `AutonomyLevel`) | The two gates agree at level 0 (both suppress) and level 2 (both send); they diverge only at level 1. `RuntimeCharacterState.TrySetAutonomyLevel` has zero production callers today, so no live code path can ever reach `AutonomyLevel == 1` — the divergence is filed for completeness, not because it is currently reachable. | The instant a future feature calls `TrySetAutonomyLevel(1)` (a partial-autonomy mode, if one is ever built), a portal-arrival movement-event ACE expects to receive at level 1 is silently dropped, until this row's fix threads the raw `AutonomyLevel` through the constructor (touching both host compositions) and gates on `!= 0` directly instead of reusing `UsePositionFromServer`. | `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (`autonomy_level != 2`); `CommandInterpreter::SendMovementEvent` @0x006B4680 (`autonomy_level != 0`, the `PlayerTeleported` tail-jump call site) | +| AP-145 | **Filed 2026-08-05 (C4 route 3, round-3 review B5; carried as issue #318).** `RuntimePlacementPresentationSink.TryPublishPlace` updates the local player's collision-shadow pose by calling `LocalPlayerShadowState.Set` DIRECTLY — a plain cache write with no side effect beyond recording `Current`. This bypasses `LocalPlayerShadowSynchronizer.SyncPose`, the ONLY call site that actually publishes to `PhysicsEngine.ShadowObjects` (`ShadowPositionSynchronizer.Sync(_physics.ShadowObjects, ...)`) — `SyncPose` calls `_state.Set(...)` itself, AFTER publishing, as its own last step. Because `SyncPose`'s own early-return dedup check compares the NEW pose against `_state.Current` (skip if the same cell and within 1e-4 m² / 0.99999 dot-product of orientation), a portal placement's direct `Set` call pre-seeds that cache with the DESTINATION pose — so the very next `SyncPose` call (the player's first post-placement movement tick) can find "nothing changed" and skip its OWN `ShadowObjects` publish too, unless the position has already drifted (settle, gravity) past the dedup threshold by then. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, the `_localPlayerShadow.Set(...)` call); `src/AcDream.App/Physics/LocalPlayerShadowSynchronizer.cs` (`SyncPose`, the dedup check and its OWN `_state.Set` call); `src/AcDream.App/Physics/LocalPlayerShadowState.cs` (`Set` — a plain cache write, no publish) | A portal placement's presentation suffix runs once per commit and its direct `Set` call is a narrow, low-frequency path; in practice the destination placement (ring search/floor snap) rarely lands EXACTLY on the pre-placement dedup-cache pose, so the next ordinary per-tick `SyncPose` call from ordinary local-player movement almost always sees the position it had already recorded and diverges from the ACTUAL committed pose by more than the threshold, triggering a real publish. This is why the risk has not been observed live. | If the destination placement happens to land within the dedup tolerance of whatever `_state.Current` held before (e.g., two placements to nearly the same spot, or the shadow was never set to begin with, or nothing calls `SyncPose` again before the player's next teleport), `PhysicsEngine.ShadowObjects` never reflects the destination — anything reading the collision shadow directly (NPC pathing around the player's own body, hit-testing) sees the PRE-teleport pose until an unrelated movement tick forces a real publish. Issue #318's composition test asserts `PhysicsEngine.ShadowObjects` directly (not the cache) to close this. | No retail analogue — retail has no separate shadow-cache/publish split; this is an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()` call can desynchronize from | | AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4B2 checkpoint 2).** Core exposes the pure retail `SetPosition` transaction; Runtime owns its exact accepted operation, complete canonical commit, deferred residence, lifetime, generation wake, revisioned host receipts, and exact-key retail collision table/environment-latch/report-result state; and one public generation-gated channel exposes observe/retry/exact-head acknowledgement without another placement queue. Collision starts, expiry/force ends, static and `ReportAsEnvironment` routing, reciprocal eligibility, missile-state clearing, callback ordering, and failed-placement `Collided` versus `NoValidPosition` classification now share one presentation-free owner. Shared local-controller body adoption remains deferred to the atomic all-route ownership cutover. Production zero-delta routes deliberately remain on the legacy resolver until 4B2 supplies exact authored mover preparation, presentation-only rebucketing, placement-prefix quiescence, and the atomic graphical/headless route cutover. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`; `tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs`; `docs/research/2026-07-31-canonical-set-position.md`; `docs/research/2026-07-31-runtime-set-position-collision-reporting.md` | The mechanism, ownership, report-result oracle, and host seam land independently without partially changing production placement behavior. | Until 4B2, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation despite the canonical owners now existing. | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `CPhysicsObj::handle_all_collisions` 0x00514780; `track_object_collision` 0x00513F10; `report_collision_end` 0x00514620; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 | | ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 | diff --git a/docs/plans/2026-08-02-placement-cutover.md b/docs/plans/2026-08-02-placement-cutover.md index 6f42e361..d3b22f1b 100644 --- a/docs/plans/2026-08-02-placement-cutover.md +++ b/docs/plans/2026-08-02-placement-cutover.md @@ -91,6 +91,34 @@ same commit) → docs/handoff commit. No workarounds; no fused slices. and local-player owners. - `RuntimePortalPlacementAuthority` has zero producing call sites; the adapter from `RuntimeWorldTransitState` does not exist. + **Corrected 2026-08-04 (C4 route 3 closure, + `docs/research/2026-08-04-c4-route-3-contract.md`), itself corrected + 2026-08-05 (A10 architecture review — the first correction asserted a + false fact of its own), and rewritten 2026-08-05 (N5 retail-review + round-3 fix — the prior wording of this correction contradicted + itself).** The original bullet conflated two separate claims into one + sentence, and only one of them was true. What pre-dated route 3 and WAS + accurate: the `RuntimePortalPlacementAuthority` type existed (referenced + by route 2's `Pending.Portal` field, always `Present: false`), its + `IsValid` check existed, and the sinks' portal-authority gates plus + `BeginAcceptedPlacementCore`'s gate already read it. What was NOT + accurate, and is what "zero producing call sites; the adapter does not + exist" actually described: the PRODUCER half — nothing built a + `Present: true` authority and called the consumer arm + (`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival`/ + `SubmitAndResolvePortal`/`ClassifyPortalArrival`) — that consumer arm + ALSO did not exist before route 3. Route 3 added the producer and the + consumer together, in the same slice: the producer is + `LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement` (now + `TryAdvancePortalCommit`/`TryExecuteCanonicalPortalPlacementCore`, per the + 2026-08-05 A1 review fix), which builds the authority from + `WorldRevealCoordinator`/`RuntimeWorldTransitState` facts and calls + `TryExecuteAcceptedPortalArrival`; the identical Runtime entry point is + shared by the headless host. So: the type/`IsValid`/consumer-gate facts + pre-dated route 3 and were true before it; the arm (both the producer + that builds a live authority and the consumer that reads one) did not + exist before route 3 and is what the original bullet's "zero producing + call sites" language was pointing at. - The exact-Setup mover chain (`PrepareMover` / `RuntimeSetPositionMoverPreparer.TryBuild` / `IPreparedCollisionSource.ReadSetupCollision`) exists piecewise, unwired. diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md index 77465aa5..67e94929 100644 --- a/docs/research/2026-07-16-portal-completion-pseudocode.md +++ b/docs/research/2026-07-16-portal-completion-pseudocode.md @@ -206,25 +206,56 @@ which confirms that worker completion alone is not draw readiness. ### 2.1. Destination placement enters the spatial cell before simulation resumes +> **2026-08-04 correction (C4 route 3, D-T9), itself corrected 2026-08-05 +> (R6 retail review):** the listing below attributes portal arrival to +> `player.enter_world(destination)`. That is wrong — a caller sweep of the +> named retail decomp +> (`docs/research/named-retail/acclient_2013_pseudo_c.txt:93770-93828`) shows +> both `CPhysicsObj::enter_world` call sites (pseudo-C `:93797` @0x004550EC +> and `:93824` @0x00455095) living inside **`SmartBox::HandleCreateObject` +> @0x00454C80** — `CObjectMaint::CreateObject` @0x00454FD8 is merely a +> *callee* it invokes partway through, not the enclosing function the first +> correction pass named. The two call sites are also **not both in the +> player branch**: @0x004550EC sits in the `if (arg3 != this->player_id)` +> NON-player branch (`PhysicsDesc::get_position` → `enter_world` for a +> newly-created REMOTE object); only @0x00455095 sits in the player branch, +> after `SmartBox::init_player` + `CellManager::ChangePosition`. Both sites +> are the LOGIN/CreateObject path that creates a physics object for the +> first time — neither is portal arrival. Portal arrival is +> `SmartBox::TeleportPlayer` (`0x00453910`) → `CPhysicsObj::SetPositionSimple` +> (`0x00453924`/`0x005162B0`) — confirmed by C4 route 3's own §1 citations +> and grep at `acclient_2013_pseudo_c.txt:92514-92521`. The conclusion below +> (commit the cell before releasing simulation) is unaffected — +> `SetPositionSimple` reaches the identical `change_cell`/`update_object` +> machinery this section describes — only the entry-point name and +> pseudocode's `enter_world` call are wrong; read `SetPositionSimple(destination)` +> wherever this section says `enter_world(destination)`. +> +> This routing is `SmartBox::TeleportPlayer` → `SetPositionSimple` +> everywhere; nothing in the passages below distinguishes retail's specific +> Recall/Lifestone/GM-teleport CAUSES, since they all funnel through the same +> accepted-destination Position at this layer. + Named retail references: - `CPhysicsObj::change_cell` at `0x00513390` - `CPhysicsObj::update_object` at `0x00515D10` -- `CPhysicsObj::enter_world` at `0x00516170` +- `SmartBox::TeleportPlayer` at `0x00453910` +- `CPhysicsObj::SetPositionSimple` at `0x005162B0` - `CPhysicsObj::prepare_to_enter_world` at `0x00511FA0` - `CPhysicsObj::set_hidden` at `0x00514C60` Retail does not separate an accepted destination Position from the object's -live cell pointer. `enter_world` runs `SetPosition`, which installs the object -in its destination `CObjCell`, before the PartArray and MovementManager -enter-world boundaries complete. `update_object` then rejects only a parented -object, a null `cell`, or a Frozen object; Hidden is not a reason to skip the +live cell pointer. `SetPositionSimple` installs the object in its destination +`CObjCell`, before the PartArray and MovementManager enter-world boundaries +complete. `update_object` then rejects only a parented object, a null `cell`, +or a Frozen object; Hidden is not a reason to skip the ScriptManager/ParticleManager tail. ```text accepted portal destination becomes ready: - player.enter_world(destination) - SetPosition(destination) + SmartBox.TeleportPlayer(destination) + SetPositionSimple(destination) change_cell(destination CObjCell) PartArray.HandleEnterWorld() MovementManager.HandleEnterWorld() diff --git a/docs/research/2026-08-04-c4-route-3-architecture-review-round2.md b/docs/research/2026-08-04-c4-route-3-architecture-review-round2.md new file mode 100644 index 00000000..c09609b0 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-3-architecture-review-round2.md @@ -0,0 +1,411 @@ +# C4 route 3 — architecture / adversarial review, ROUND 2 (delta) — 2026-08-05 + +**Verdict: FAIL.** + +Reviewed: the uncommitted working tree at HEAD **`cd3129e9`**, +2,570/-232 across +16 files. Round-1 report: +[`2026-08-04-c4-route-3-architecture-review.md`](2026-08-04-c4-route-3-architecture-review.md). +`dotnet build AcDream.slnx -c Debug` exits 0. + +**Round-1 findings closed: A4, A6, A9, A10(a).** A1, A2, A3 were addressed with +real design work that is directionally right — the A1 fix in particular +(inverting the readiness feed instead of touching the sequencer) is the correct +architectural answer to a hard constraint, and I want that stated plainly. + +**The FAIL is one defect, present symmetrically on both hosts, introduced by +the A1/A3 fixes themselves:** both new "am I committed yet?" gates infer +*commit* from *the drive controller's global pending slot being empty*. That +slot empties on at least three paths that do **not** commit — including the one +the drive's own doc comment names as the *expected* outcome of a park. When it +does, the graphical controller latches `_placementCommitted = true` and the +headless projection reports `IsCollisionReady: true`, and both hosts then march +the full completion sequence against an unmoved body. That is round-1's A1/A3 +restored, and on the graphical side it is now *worse*, because +`AcknowledgePortalMaterialized` succeeds where it previously failed its +invariant. + +Numbering continues as **B*n*** to avoid collision with round 1. + +--- + +## MAJOR — the FAIL + +### B1 — `PendingCount == 0` is not "committed"; both hosts infer commit from a signal that is also set by three non-committing paths + +**Severity: MAJOR (FAIL basis). Both hosts. Uncovered by any test.** + +Graphical, `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:646-658`: + +```csharp +if (_awaitingDeferredWake) +{ + if (_acceptedPositionDrive.PendingCount != 0) + return false; + _awaitingDeferredWake = false; + _placementCommitted = true; // <-- infers commit from "not pending" + return true; +} +``` + +Headless, `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:816-821`: + +```csharp +if (_awaitingPortalWake) +{ + committed = _acceptedPositionDrive.PendingCount == 0; + if (committed) + _awaitingPortalWake = false; +} +``` + +`PendingCount` is +`RuntimeAcceptedPositionDriveController.cs:330` — `_pending is null ? 0 : 1`. +It is (a) **global**, not portal-scoped, and (b) cleared by every terminal path, +committing or not. `Advance()` clears `_pending` at five sites; three of them +run **without** a portal commit: + +| site | condition | committed? | +|---|---|---| +| `:975` | *"The watch died — most likely a subsequent accepted Position's merge-time `Forget`"* | **no** | +| `:920` | A2's new abandon-at-wake (`!IsPortalAuthorityCurrent`) | body moved by `RetryDeferred`, **suffix skipped** | +| `:998` | A2's new abandon at the prepare-retry branch (`CancelToken`) | **no — nothing ever placed** | + +**The `:975` path is the modal case, not a corner case.** The method's own +doc comment (`:880-892`) says it verbatim: + +> `RuntimeEntityObjectLifetime.TryApplyPosition` calls `Forget` on EVERY +> accepted Position for this entity … ACE broadcasts at 5-10 Hz, so a +> `DeferredCell` park surviving past one broadcast interval is cancelled +> before its collision generation can ever commit it — **the exact +> far-destination case the park exists to serve**. + +So: park → within ~100-200 ms an ordinary broadcast `Forget`s it → `Advance` +clears `_pending` → `PendingCount == 0` → both gates declare success. + +**Concrete failure scenario (graphical).** Portal to a landblock whose +collision generation has not committed. `TryExecuteAcceptedPortalArrival` +returns `DeferredCell`; `_awaitingDeferredWake = true`. One ACE broadcast +later the park is Forgotten and `_pending` clears. Next `Tick`: +`_placementCommitted = true` → `placementReady = true` → the sequencer leaves +`Tunnel` and fires `Place` → the `if (!_placementCommitted) return;` guard at +`:557` **passes** → `_placement.Place(_pendingRotation)` writes the render +entity from the *unmoved* `controller.Position` and rebuckets to the *source* +cell → `ObserveMaterialized(_pendingRevealGeneration, sequence, _pendingCell)` +**succeeds** (the reveal is still active and current) → `PlayExitSound` reveals +the world viewport → `FireLoginComplete` sends LoginComplete, and +`_transit.Complete(generation)` now **passes** its +`portal-complete-before-materialized` check because materialization was falsely +acknowledged. The player is released into the world standing at the +pre-teleport position, the transit reports a clean completion, and nothing logs +an invariant failure. Round 1's A1 at least tripped +`FailInvariant("portal-complete-before-materialized")`; this does not. + +**Concrete failure scenario (headless).** Identical shape: +`PrepareDestination` returns `IsCollisionReady: true`, so +`RuntimeLiveEntitySessionController.TryAdvancePortalCompletion` runs +`AcknowledgeDestinationReadiness` → `AcknowledgePortalMaterialized` → +`Complete` → `TerminalProjected` → `LoginComplete` → `EndTeleport`, and sets +`controller.State = PlayerState.InWorld`, all with the body unmoved. That is +round-1 A3 verbatim. + +**Secondary hazard from the same root:** because the slot is global, a portal +park killed by a merge-time `Forget` can be immediately replaced by the force +arm's own `RetainPending` from that same merge +(`TryExecuteAcceptedLocalPosition` → `RetainPending`). The portal gate then +polls a **ForcePosition** operation, waits for it, and latches "portal +committed" when the force operation settles. + +**Why no test caught it.** Both new park tests +(`PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake`, +`HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake`) +commit the destination's collision generation so the park resolves by +committing. `PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale` +asserts the **drive's** behaviour (`Assert.Empty(gameActions)`) and stops +there — it never asks what the *host gate* concludes from the resulting +`PendingCount == 0`. The seam between "the drive retired the park without +committing" and "the host decides the portal is placed" is exactly where the +defect lives and is exactly what no test crosses. + +**Fix direction.** Stop inferring. The drive already knows the answer with +certainty — `ReconcileAndAcknowledgePortal` runs on commit and only on commit. +Publish that fact: + +- add a portal-commit observable to `RuntimeAcceptedPositionDriveController` + — e.g. `bool TryConsumePortalCommit(long revealGeneration, ushort teleportSequence)` + latched in `ReconcileAndAcknowledgePortal` and cleared on consumption, or an + `Action` commit callback supplied at + construction alongside `isPortalAuthorityCurrent`; +- have both host gates consume **that**, keyed on the reveal + generation/sequence they are waiting for, so a force pending, a Forgotten + park, and an abandoned park are all correctly "still not committed"; +- give the park a terminal "abandoned" outcome the host can see, so it can + either re-attempt cleanly (`_awaitingDeferredWake = false` and try Begin + again next tick) or converge through the existing transit cancellation + rather than silently succeeding. + +**Required tests (both must fail against the current code):** +1. Graphical: park, then kill the park with a merge-time `Forget` (an ordinary + accepted Position — the fixture's `OfferDestination` already performs the + merge) instead of committing the collision generation; drive 100 ticks; + assert `Placement.Called == false`, `Movement.Controller.Position` unchanged, + `Reveal.PortalMaterializationCount == 0`, `Session.LoginCompleteCount == 0`, + `Controller.IsActive == true`. +2. Headless: same, asserting `PrepareDestination` keeps returning + `IsCollisionReady: false` and `controller.State` stays `PortalSpace`. +3. Graphical: park, then let the force arm take the pending slot; assert the + portal gate does not latch when the *force* operation settles. + +--- + +## MEDIUM + +### B2 — A2's re-validation does not close the FIFO wedge it was written for; it only narrows reachability + +The implementer's own note is accurate and I confirm it: `RetryDeferred` → +`CommitCanonical` publishes the `Place` receipt asynchronously, and +`IsPortalAuthorityCurrent` runs only afterwards, inside `Advance`, gating the +*suffix*. But the wedge was never in the suffix — it is in the receipt: + +- `Advance:920`'s abandon branch is reached only **after** + `TryPeekAcknowledgedPlacement` succeeded, i.e. the sink **already accepted** + the receipt. On that path there was never a wedge to prevent. +- The wedge path is the one where the sink **refuses**: + `RuntimePlacementPresentationSink.TryApply` → + `RuntimeWorldTransitState.IsCurrentPlacementAuthority` false → return false → + `RuntimePlacementProjectionSubscription.OnPlacement` leaves it at the FIFO + head → every later placement receipt for every entity is blocked and the + drive's pending never converges. `IsPortalAuthorityCurrent` never runs on + that path, because `TryPeekAcknowledgedPlacement` never yields. + +What the A1 fix *does* buy is reachability: with `placementReady` false during +a park, the sequencer cannot reach `FireLoginComplete`, so the transit no +longer ends underneath an outstanding park in the ordinary flow. The remaining +entries are mid-transit supersession (a second F751 while parked → +`OnTeleportStarted` → `ResetTransit` → `EndTeleport`), `ResetSession`, and +`ResetGenerationPresentation` — i.e. exactly the §9 "honest gap" cases. + +That is a genuine narrowing and I credit it. It is not closure, and the +consequence remains unbounded (whole-FIFO stall + non-convergent shutdown), so +contract stop condition 4 ("P3 finds a portal receipt no mechanism can consume +or retire — the FIFO-wedge shape changes the design, not the test") still +applies. + +**Fix direction.** The retire path has to exist at the receipt, not the suffix. +Either (a) let the sinks treat a `Place` whose entity/versions are current but +whose portal authority is dead as acknowledge-and-ignore (the same shape +`Discard`/`ExecutorCompleted`/`WithdrawalRestored` already use, and for the +same stated reason — refusing wedges the ordered stream), or (b) have +`CommitCanonical` drop a portal suffix it can already see is not current rather +than publishing a receipt nothing can consume. (a) is smaller and matches the +existing precedent in both sinks' own doc comments. + +### B3 — Headless `_awaitingPortalWake` is not reset across teleports within one session + +`HeadlessSessionWorldProjection.cs:762`. The graphical twin +(`_awaitingDeferredWake`) is cleared in `ResetTransit:932`, which +`OnTeleportStarted` calls — clean. The headless field has no equivalent: it is +cleared only when the poll declares success, and `HeadlessSessionWorldProjection` +is constructed per *session*, not per teleport. + +**Scenario.** Teleport 1 parks → `_awaitingPortalWake = true`. Before the next +`PumpPortalCompletion`, a new F751 arrives; `TryCompletePortal` overwrites +`_pendingPortalCompletion` with reveal 2. The next pump calls +`PrepareDestination(reveal 2)`, which takes the **poll** branch left over from +reveal 1 — so reveal 2's placement is never even attempted, and if +`PendingCount` happens to be 0 it is immediately declared committed. This is +B1's inference bug plus a stale latch, on a path that does not require a +Forgotten park. + +**Fix direction.** Key the latch to the reveal generation (or clear it in +`BeginTeleport`, which already runs per teleport on this host). + +### B4 — Both hosts' retry loops are unbounded and undiagnosable + +A refusal loop (`Contention`, stale reveal, or a park that never converges) +now retries forever with no timeout and no terminal path. + +- **Graphical**: the player holds in the tunnel with the retail wait cue after + 5 s. This is a *modelled* end state — AD-2 already documents "predicate never + satisfies → portal transit remains in the authored tunnel and presents the + centered wait cue" — so an infinite stall is strictly better than round 1's + silent release, and I do not consider it blocking. Two gaps, though: AD-2 + attributes that state to streaming/DAT failure only, and now a *placement* + refusal produces the identical user-visible state; and `_holdSeconds` / + `ObserveWait` are now driven by `!placementReady` rather than `!dataReady`, + which is a real semantic change to the wait cue's meaning. The + `[tp-probe] REFUSED cause=…` line does distinguish them in the log — good — + but AD-2 should say so. +- **Headless**: worse, because there is no cue and no bound. A bot whose + destination collision never becomes resident sits in `PlayerState.PortalSpace` + indefinitely, connected and healthy-looking; `PumpPortalCompletion` is + entirely silent. Under K4's 30-session envelope this is an invisible stuck + session. At minimum emit one probe line on the first N retries; ideally bound + the wait and fail loudly. + +### B5 — §8 items 8/9/10 (the committed-receipt presentation suite) — **does NOT block**, with conditions + +Answering the coordinator's direct question. + +**Does not block route 3.** Reasons, in order of weight: + +1. The render-entity half now has a *proven mechanism*, not a claim. + `LiveEntityRuntime.TryApplyRuntimePlacementPlace:1394-1402` performs + `entity.SetPosition(projection.WorldPosition)`, `entity.Rotation = …`, + `entity.ParentCellId = token.ExactCellId` and `RebucketLiveEntity`, + synchronously inside `CommitCanonical`, before `TryPublishPlace` snapshots. + That path is shared with route 2's force arm and C3c's first entry and has + existing coverage. Round 2 correctly rewrote the class doc to say so + (round-1 A10(a) closed). +2. The restored `Movement.Controller.Position`/`CellId` assertions prove the + canonical body resolved the offered destination — which is the half route 3 + actually changed ownership of. +3. The shadow half is a **pre-existing route-2 defect**, not a route-3 + regression: `LocalPlayerShadowState.Set` (written by the sink) updates only + the dedup cache, never `PhysicsEngine.ShadowObjects`, while + `LocalPlayerShadowSynchronizer.SyncPose` dedups against that same cache. It + self-heals on the player's first >1 cm move. Route 3 widens the window (via + `LocalPlayerProjectionController.Project:102`'s PortalSpace early return) but + does not change its kind. + +**Conditions — all three, or it does block:** + +- It is recorded as an **open issue with a number** (#312's layer / route 2's + B2 gap, now two campaigns old) and carried explicitly into C5's parity-test + scope. Not a comment; a tracked item. +- The connected gate is **not** scored as covering it. The probe fields + (`leash`, `autorun`, `hookTail`) say nothing about the shadow; there is no + visual for it. If the user's session passes, the shadow claim remains + test-verified-nowhere and must be reported that way (the §9 "honest gap" + discipline). +- The register gets one line under AD-2 or a sibling row naming the + cache-without-publish asymmetry, so the next reader does not assume + `LocalPlayerShadowState.Current` means "published". + +**Concretely, what the test needs** (in +`tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs`, whose +fixture already owns `BeginPortal` and a `LocalShadow`): + +1. **Committed local-player portal Place through the real sink.** Pre-seed the + render `WorldEntity` at a *different* (wire) pose — this is also §8 item 9's + T8-ordering half. Drive a `Place` receipt for the local player carrying a + VALID portal authority. Assert, after: `entity.Position`/`Rotation`/ + `ParentCellId` equal the receipt's `WorldPosition`/`Orientation`/ + `ExactCellId` (the wire pose did not survive); the spatial bucket moved to + the destination landblock; `LocalShadow.Current` equals the resolved pose; + **and `PhysicsEngine.ShadowObjects` actually holds a row for the player at + the destination cell** — that last assertion is the one that discriminates + cache-only from published, and is the whole point. +2. **Discrimination half.** The same receipt with a stale/superseded portal + authority must be refused **and then retired** — not left at the FIFO head. + This is also B2's regression test. +3. **Refused-Place presentation (§8 item 10).** Already partly covered by + `RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears`; extend it + to assert the pre-teleport pose is still the *presented* pose (entity + + world snapshot store), not only that the body is unmoved. +4. **Sabotages that must fail:** remove the sink's `entity.SetPosition` → (1) + fails; leave the shadow write as cache-only → (1)'s `ShadowObjects` + assertion fails; make the stale-authority receipt return `false` forever → + (2) fails. + +--- + +## MINOR + +### B6 — `isPortalAuthorityCurrent` should be a required constructor parameter + +The coordinator's specific concern, checked: **both production sites wire it** — +`SessionPlayerComposition.cs:597-603` and `HeadlessSessionHost.cs:665-671`, +both to `RuntimeWorldTransitState.CanPlacePortalDestination`. Four test sites +do not, which is fine. + +The residual is that a null default silently restores the round-1 defect, and +nothing catches a future production site that forgets it — there is no +architecture guard for this the way +`RuntimePhysicsOwnershipTests.ProductionHostsUseSharedPlacementSubscriptionWithoutDirectChannel` +guards the placement channel. With only 2 production + 4 test constructions, +making the parameter required (tests pass `static _ => true`) converts a silent +regression into a compile error for ~6 lines of churn. Same argument will apply +to B1's commit observable. + +### B7 — `_placementCommitted` is checked once and never re-validated + +`LocalPlayerTeleportController.cs:557`. Round 1's Place handler ran +`CanPlacePortalDestination` immediately before mutating; round 2 moved that +check into `TryAdvancePortalCommit`'s **non-deferred** branch only +(`:668-676`). Once `_placementCommitted` latches, the only guard before +`_placement.Place()` / `ObserveMaterialized` is `IsCurrentLifetime`. If the +reveal is cancelled between the commit and the Place event, +`ObserveMaterialized` refuses (`IsCurrentPortalDestination`) but the +presentation suffix has already run and the stream continues to +`FireLoginComplete` with an unmaterialized reveal. Much less severe than B1 — +the body genuinely is at the destination — but it is the same family. Cheap +fix: keep the `CanPlacePortalDestination` re-check in the Place handler +alongside `_placementCommitted`. + +### B8 — A8 (round 1) confirmed still open and confirmed non-blocking + +Five portal tests still call `ConvergePortalHost` as the last statement of the +test body rather than in a `finally`, so an assertion failure is still masked +by a `Dispose()` throw during unwinding. Correctly flagged rather than silently +dropped. Test hygiene only — no production effect. It does mean that when B1's +new tests are written, a genuine failure may again present as a teardown throw; +fixing the `try/finally` first would save that debugging round. + +### B9 — round-1 A11/A12 unchanged + +`PhysicsDiagnostics.LocalTeleportHostKind` process-global (accepted); +`AddSyntheticIndoorCell` geometry-free (accepted — and less load-bearing now +that A6's position assertions are restored). + +--- + +## Closed since round 1 + +| round-1 finding | status | evidence | +|---|---|---| +| **A4** — route facts unread, inversions hardcoded | **CLOSED** | `RuntimeAuthoritativePositionRoute.RunsTeleportHook:164` / `.ConstrainAfterRouting:170`; `ReconcileAndAcknowledgePortal` reads both; `CommitCanonicalTeleportFrame(bool zeroVelocity, bool rearmConstraintLeash)` branches on them. `ConstrainPhase.None` sabotage now fails `PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent`'s stale-anchor assertion, as the contract's §8 item 11 intended. | +| **A6** — superseded-teleport discriminator removed | **CLOSED** | `NewerStart_ReplacesOldDestinationWithoutReusingIt` asserts `Movement.Controller.Position.X/Y == 2` and the cell; `SameLandblockDestination_…` asserts `(20,30,4)` + `0x20210123`; the Z-vs-X/Y reasoning in the comment is sound. | +| **A9** — two sequence sources | **CLOSED** | The authority now uses `destination.TeleportSequence`, with a `Debug.Assert` pinning it equal to the caller's copy. | +| **A10(a)** — class doc claimed the suffix was the render entity's only mover | **CLOSED** | Doc rewritten to state the writes are redundant repeats of the canonical receipt's mutation, with the ordering cited. | +| **A1** — refused Place did not stop the anim stream | **partially** — the mechanism is right (readiness inversion, sequencer untouched), and the `Contention` path is now correctly held and tested (`RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears`). **B1 reopens it for the `DeferredCell` path.** | +| **A2** — no wake re-validation | **partially** — D-T2.4's re-validation now exists and is wired in both hosts and tested; **B2** shows it does not close the FIFO wedge. | +| **A3** — headless discarded the status | **partially** — status now honoured, throws on `Rejected`/`NotApplicable` and on a missing drive, retryable pump added. **B1/B3 reopen it.** | +| **A5** — presentation suite | **still open** — see B5 for the definite blocks/does-not-block answer. | +| **A7** — dual-host parity | **CLOSED** | `HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake` drives a real wired drive end to end and asserts body position, cell, and `PlayerState`. | +| **A8** | open, non-blocking (B8). | +| **A11 / A12** | unchanged (B9). | + +## Also verified this round + +- `_pendingDestination` lifetime — round-1 judgment stands, not re-litigated. +- The sequencer's own invariants survive the late `worldReady`: `Tunnel` is + explicitly a hold state (*"Hold here until worldReady"*), `TickTunnel` still + runs on the hold path so `CurrentAnimationFrame` keeps advancing into + `TunnelContinue`'s exit window, and `maxForce` at 5 s covers a stale frame. + `worldReady` has exactly one consumer (`case TeleportAnimState.Tunnel`), so + no other transition changed meaning. +- No double-Begin headless: `TryCompletePortal`'s own + `TryGetAcceptedTeleportDestination`/`TryBeginPortalReveal` prefix cannot + succeed twice for one reveal (the destination slot is consumed), and + `_awaitingPortalWake` suppresses a second concurrent `Begin` while parked. + (The stale-latch problem is B3, a different failure.) +- `_placementCommitted` / `_awaitingDeferredWake` are both cleared in + `ResetTransit:931-932`, so the graphical latches do not survive a new F751, + a session reset, or a generation reset. +- Build green. + +--- + +## Summary + +| # | Severity | Finding | +|---|---|---| +| B1 | MAJOR | `PendingCount == 0` inferred as "committed" on both hosts; three non-committing paths clear it, including the drive's own documented modal outcome. Reintroduces A1 (graphical, now with a *successful* false materialization) and A3 (headless). Untested. | +| B2 | MEDIUM | A2's re-validation gates the suffix, not the receipt; the FIFO wedge is narrowed (supersession/reset only) but not closed. | +| B3 | MEDIUM | Headless `_awaitingPortalWake` is not reset per teleport; a stale latch can skip reveal N+1's placement attempt entirely. | +| B4 | MEDIUM | Unbounded, undiagnosable retry loops on both hosts; headless has no cue, no bound, and no log. | +| B5 | — | §8 8/9/10 presentation suite: **does not block**, subject to three stated conditions; required test spelled out. | +| B6 | MINOR | Make `isPortalAuthorityCurrent` (and B1's commit observable) required constructor parameters. | +| B7 | MINOR | `_placementCommitted` never re-validated before the presentation suffix. | +| B8 | MINOR | A8 still open (5 tests), confirmed non-blocking. | +| B9 | MINOR | A11/A12 unchanged. | diff --git a/docs/research/2026-08-04-c4-route-3-architecture-review.md b/docs/research/2026-08-04-c4-route-3-architecture-review.md new file mode 100644 index 00000000..1a9e2e90 --- /dev/null +++ b/docs/research/2026-08-04-c4-route-3-architecture-review.md @@ -0,0 +1,550 @@ +# C4 route 3 — architecture / adversarial review (2026-08-04) + +**Verdict: FAIL.** + +Reviewed: the uncommitted working-tree diff (`git diff HEAD` + untracked) on +`claude/acdream-physics-divergence-5aa784` at HEAD **`cd3129e9`** (route 7's +commit, "child cell propagation moves from a render tick into Runtime"). 16 +files, +1,573/-216. + +Reference documents read in full: the route-3 contract +(`2026-08-04-c4-route-3-contract.md`), the route-3 scoping, the route-2 +contract, and the route-5/route-7 review defect classes. + +Independent verification performed against source, not against the +implementer's summary: `RuntimeWorldTransitState`, `RuntimeSetPositionState`'s +commit tail, `RuntimeEntityObjectEventStream`/ +`RuntimePlacementProjectionSubscription` (publication synchronicity), +`RuntimePlacementPresentationSink` + `LiveEntityRuntime +.TryApplyRuntimePlacementPlace`, `LocalPlayerShadowSynchronizer`, +`LocalPlayerProjectionController`, `TeleportAnimSequencer`, and both hosts' +`Advance()` pump sites. `dotnet build AcDream.slnx -c Debug` exits 0. + +**The `_pendingDestination` fix — the item flagged as highest risk — is +correct, and is not the reason for the FAIL.** See §"Judgment on the +`_pendingDestination` lifetime" at the end. + +The FAIL rests on A1 and A2: the slice adds five new ways for the Place edge +to refuse, and the refusal path it hands them to does not stop the teleport +animation stream. One of those five (`DeferredCell`) additionally commits the +placement out of band after the transit has ended, which both splits the body +from presentation and leaves a placement receipt nothing can consume — proof +obligations **P3** (graphical half) and **P4** are undischarged, and D-T2.4's +"the wake path must re-validate the portal authority before committing" is not +implemented at all. + +--- + +## MAJOR + +### A1 — A refused canonical Place does not stop the teleport animation; the player is released into the world without ever having been placed + +**Severity: MAJOR (FAIL basis).** + +`src/AcDream.Core/World/TeleportAnimSequencer.cs:134-142`: + +```csharp +case TeleportAnimState.Tunnel: + if (worldReady) + { + evts.Add(TeleportAnimEvent.Place); + Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); + _continueElapsed = 0f; + } + break; +``` + +`TeleportAnimEvent.Place` is emitted **exactly once**, and the sequencer +advances to `TunnelContinue` in the same statement block, unconditionally and +with no knowledge of whether the consumer's handler succeeded. There is no +path back to `Tunnel`. + +`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:505-526`: + +```csharp +case TeleportAnimEvent.Place: + if (!_worldReveal.CanPlacePortalDestination(...)) return; + if (!TryExecuteCanonicalPortalPlacement(sequence)) + return; // <- new in this slice + ... + _placement.Place(_pendingRotation); + ... + _worldReveal.ObserveMaterialized(...); +``` + +The `return` exits `Tick`, but the sequencer has already left `Tunnel`. Every +subsequent `Tick` therefore runs the rest of the stream with **no placement +and no materialization**: + +| next event | what runs | +|---|---| +| `TunnelContinue` → `TunnelFadeOut` | — | +| `PlayExitSound` (`:532-542`) | `_worldReveal.RevealWorldViewport()` + `_presentation.ExitTunnel()` | +| `FireLoginComplete` (`:543-554`) | `_mode.EnterWorld()`, `_session.SendLoginComplete()`, `_worldReveal.Complete()`, `ResetTransit(clearSession: false)` | + +`WorldRevealCoordinator.RevealWorldViewport` only needs a live host projection +(`WorldRevealCoordinator.cs:258-266`) — present. `Complete()` reaches +`RuntimeWorldTransitState.Complete` (`:701-706`), which hits +`FailInvariant("portal-complete-before-materialized")` and returns `false`; +`FailInvariant` (`:934-946`) only increments a counter and logs — it does not +throw and does not stop the caller. `ResetTransit` then runs `EndTeleport()` + +`_worldReveal.Cancel()`. + +**Concrete failure scenario.** A `Contention` outcome (route 2's force arm, a +route-7 parent drive, or an earlier park still owns the entity's placement +token at the Place edge) makes `TryExecuteAcceptedPortalArrival` return +`Contention` at `RuntimeAcceptedPositionDriveController.cs:492-501`. The +player watches the portal tunnel finish normally, the world viewport is +revealed, `LoginComplete` is sent to ACE — and the player is standing at the +**pre-teleport position** in the **pre-teleport cell**. ACE has them at the +destination. Every subsequent server broadcast fights the client. Nothing in +the client logs above `[world-reveal] event=invariant-failure` (a `SafeLog` +line), and the D-T8 probe never emits because +`ReconcileAndAcknowledgePortal` never ran. + +**Why this is the slice's problem and not inherited.** The pre-existing +`CanPlacePortalDestination` early return (`:506-512`) has the same shape, but +it fires only when the transit no longer owns this reveal — a case where +marching on is at worst redundant, because a newer transit owns the world. +This slice adds **five new refusal causes that all fire while the transit is +perfectly healthy**: `host-token-unavailable`, `NotApplicable`, `Rejected`, +`Contention`, and `DeferredCell` (`LocalPlayerTeleportController.cs:596-625`, +which treats everything except `Committed` as a refusal at `:619`). + +**Contract obligations violated.** §4 item 4 ("on every refusal … the transit +remains coherent … never a half-state … no path leaves the player permanently +in portal space with a dead operation"); §4 item 5 ("a refused placement must +NOT … `RevealWorldViewport`, must NOT advance the anim-event stream's terminal +events"); D-T5's Begin-refusal row ("the anim stream stays where it is, so the +NEXT Tick re-attempts the Place edge … if the Place anim event is one-shot, +the re-attempt must be driven by the same Tick predicate that produced it, and +THAT mechanism must be stated in the commit"); and proof obligation **P4** +verbatim. P4 was to be discharged by reading `TeleportAnimSequencer`. It is +one-shot. No re-attempt driver exists. + +**Fix direction.** Two shapes are available without touching the sequencer +(stop condition 2 forbids sequencer timing changes): + +1. Make the Place edge idempotent-and-latched at the controller: keep a + `_placementCommitted` flag; on a refusal, do NOT let the stream reach + `PlayExitSound`/`FireLoginComplete` — gate those two cases on the latch and + drive a bounded re-attempt from the same `ready` predicate that produced + the Place event (the contract's stated fallback). A refusal that never + converges must then take the existing transit cancellation + (`ResetTransit(clearSession: false)` — which already cancels the reveal and + restores presentation) rather than a silent world release. +2. Or treat a refusal as an immediate transit cancellation and let the + existing supersession path own recovery. Louder, smaller, and it satisfies + D-T5's "never a silent wedge" — but it needs the user's eyes because it is + user-visible (the portal fails and the player stays put) rather than a + silent desync. + +Either way this needs a test: *refused arm → the anim stream does not reach +`RevealWorldViewport`/`FireLoginComplete` with an unplaced body.* + +--- + +### A2 — A `DeferredCell` portal park splits the body from presentation and leaves a placement receipt nothing can consume (P3 undischarged on the graphical host; D-T2.4's re-validation missing) + +**Severity: MAJOR.** + +`RuntimeAcceptedPositionDriveController.SubmitAndResolvePortal:614-645` parks a +`DeferredCell` outcome into `_pending` carrying the portal authority. +`LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement:619` returns +`false` for it, so A1's march runs: the stream reaches `FireLoginComplete`, +`ResetTransit` calls `_transit.EndTeleport()` and `_worldReveal.Cancel()`. + +The park is still live. `Advance()` is pumped by the graphical host at +`src/AcDream.App/Net/GraphicalSessionEventRoute.cs:117` and +`src/AcDream.App/World/LiveEntityHydrationController.cs:416`. When the +destination landblock's collision generation eventually commits: + +- `Advance:763-771` runs `ReconcileAndAcknowledgePortal` — the body moves, the + leash re-arms, autorun cancels, and **one outbound movement event is sent** — + seconds after the player was already released into the world at the old + position. Presentation is never told: `_placement.Place` and + `ObserveMaterialized` are unreachable (the anim event is one-shot, A1). +- The commit publishes a `Place` receipt whose `Token.Portal` still names the + ended reveal. `RuntimeWorldTransitState.IsCurrentPlacementAuthority:258-275` + requires `IsCurrentPortalDestination` (`:870-882`), which requires + `_teleportActive` — cleared by `EndTeleport` (`:547-556`). It returns false + **forever**. +- `RuntimePlacementPresentationSink.TryApply:100-106` therefore returns + `false`; `RuntimePlacementProjectionSubscription.OnPlacement:134-136` leaves + the receipt at the FIFO head. Every later placement receipt **for every + entity** is blocked behind it, and `AcceptedPositionDrivePendingCount` never + returns to zero, so `GameWindowLifetime.DisposeGameRuntime` throws on + shutdown. + +That is precisely the failure mode P3 exists to rule out ("A receipt nothing +can ever consume or retire is a FIFO wedge"). P3 was discharged only for the +headless *happy path*; the graphical park was not walked. + +Independently, **D-T2.4's explicit requirement is not implemented**: "its wake +path must re-validate the portal authority before committing". Neither +`Advance`'s `AwaitingCommitWake` branch (`:763-771`) nor its +`IsPlacementCurrent` re-submit branch (`:813-819`) re-checks the portal +authority — they pass `pending.Portal` straight through. + +**Fix direction.** (a) Re-validate the portal authority at both wake points +(`_entityObjects` has no transit handle today — the drive needs a +`Func`-style currency predicate or the authority passed back through the +transit owner); on failure take `AbandonPending`'s exact shape +(`restoreCancelledPark: true` + `PublishCancellation`) so the park is retired +rather than committed. (b) Independently, A1's fix must prevent the transit +from ending while a portal park is outstanding. + +--- + +## MEDIUM + +### A3 — Headless discards the arm's status entirely; a failed placement is silent + +`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:768-780`: + +```csharp +if (_acceptedPositionDrive is not null) +{ + var authority = new RuntimePortalPlacementAuthority(...); + _ = _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(destination, authority); +} +``` + +Two problems. First, the status is discarded: any non-`Committed` outcome +leaves the body unmoved while `TryCompletePortal` +(`RuntimeLiveEntitySessionController.cs:530-585`) proceeds through +`AcknowledgeDestinationReadiness` → `AcknowledgePortalMaterialized` → +`Complete` → `TerminalProjected` → `LoginComplete` → `EndTeleport` — asserting +a materialization that did not happen (§4 item 5) and telling ACE the login +completed. The deleted `ResynchronizeLocalPlayerForPortalArrival` was +unconditional and could not fail this way. Second, the `is not null` guard +means a composition regression that fails to wire the drive silently disables +headless portal placement with no signal at all; the previous code had no such +mode. + +**Fix direction.** Treat a non-`Committed` status as a hard failure on this +path (`TryCompletePortal` already throws on every other Runtime refusal — +match that), and make the drive a required constructor dependency for the +production projection. + +### A4 — Both inversions are hardcoded; the classifier route facts that encode them are never read + +`ReconcileAndAcknowledgePortal(RuntimeEntityRecord, in RuntimeAuthoritativePositionRoute route, in RuntimePortalPlacementAuthority)` +(`RuntimeAcceptedPositionDriveController.cs:667-698`) **never references +`route`**. `PlayerMovementController.CommitCanonicalTeleportFrame:1987-2035` +unconditionally zeroes velocity, runs `StopCompletelyAtPhysicsObjectBoundary`, +`UnStick`/`UnConstrain`, and `RearmConstraintLeashAtCurrentPosition`. + +So `route.ZeroVelocity`, `route.ConstrainPhase`, and `route.TeleportHookPhase` +are recorded-not-consumed, even though D-T2.3 pinned "`route.ZeroVelocity` is +honored at the commit". The visible behaviour is correct today only because +the classifier's LocalPlayer-teleport branch happens to agree with the +hardcoded method. + +Consequence for test quality: the contract's own §8 item 11 sabotage — +"hardcode the force route onto the portal arm (`ConstrainPhase.None`) → test +5a fails" — **cannot fail**, because no code path reads `ConstrainPhase`. No +test discriminates the classifier from the executor. A future classifier edit +(the classifier is a shared surface routes 2/4b-2/4b-3 also consume) diverges +from behaviour silently. + +**Fix direction.** Either consume the route facts in the frame commit (branch +on `ZeroVelocity`/`ConstrainPhase`) and add the discriminating test, or delete +the unused `route` parameter and state explicitly, in the class doc and in +AD-2, that the inversions are enforced by `CommitCanonicalTeleportFrame` and +NOT by the classifier — so the next reader does not trust a route fact that +nothing reads. + +### A5 — Contract §8 item 8's committed-receipt presentation suite is missing; invariant 6's "the local-player collision shadow agrees" is asserted nowhere + +No test in this diff drives a committed portal placement through the **real** +`RuntimePlacementPresentationSink` plus the suffix. +`tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs` is +untouched; its only portal test +(`PortalPlace_RequiresExactCurrentTransitHostAndSequence:467`) drives a +synthetic token, not a local-player placement. The App teleport tests assert +`harness.Placement.Called` against a *fake* placement. + +That missing suite would have surfaced the following latent inconsistency, +which route 3 newly makes reachable on the portal path: + +- `RuntimePlacementPresentationSink.TryPublishPlace:226-232` writes + `_localPlayerShadow.Set(entity.Position, entity.Rotation, record.FullCellId)`. + `LocalPlayerShadowState.Set` updates **only the cache** — it never touches + `PhysicsEngine.ShadowObjects`. +- `LocalPlayerShadowSynchronizer.SyncPose:59-70` dedups against that same + cache (`cellId` equal AND position within 1 cm AND orientation within + tolerance ⇒ return without publishing). +- `LocalPlayerProjectionController.Project:102` early-returns for + `PlayerState.PortalSpace`, so the sink's cache write is the last word until + the player re-enters the world. + +Net: the first post-arrival `SyncShadow` sees a cache that already claims the +resolved pose and resolved cell, skips, and the player's collision shadow row +is not published at the destination. It self-heals the first time the player +moves more than ~1 cm, so the window is short — but during it, other entities +have no collider for the player at the destination. (The shape is pre-existing +from route 2's force arm, where `Project` runs every frame so the window is +one frame; route 3's portal-space skip widens it.) + +**Fix direction.** Write the §8 item 8 suite. Independently, either have the +suffix re-publish the shadow through `LocalPlayerShadowSynchronizer` +(`force: true`) after writing the resolved entity pose, or stop the sink from +writing a "last published" cache entry it did not publish. + +### A6 — The one test that discriminated *which* destination gets placed lost its discriminator + +`tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs`, +superseded-teleport scenario: + +```diff +- Assert.Equal(new Vector3(2f), harness.Placement.Position); ++ Assert.True(harness.Placement.Called); +``` + +The old assertion proved the **second** destination `(2,2,2)` was placed and +not the first `(1,1,1)`. `Assert.True(Called)` cannot distinguish them. That is +exactly the property the `_pendingDestination` caching change puts at risk +(caching an Aim-time value instead of re-reading). Two sibling tests lose the +same class of assertion, and one now contradicts its own name: +`SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition` no longer +asserts any position or cell. + +This is the route-5 "tests that assert only negatives"/weakened-successor +defect class. The replacement assertion is **available**: the harness now owns +a real Runtime `RuntimeLocalPlayerMovementState`, so +`Assert.Equal(expected, movement.Controller!.Position)` and `.CellId` are +reachable; the harness simply does not expose `movement`. + +**Fix direction.** Expose the Runtime controller on the harness and restore a +positive position/cell assertion in each of the three tests, at minimum in the +superseded-teleport one. + +### A7 — Dual-host parity (§8 item 6) is not met; the headless flip is untested + +`tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs:413-424` now +documents that the fixture's projection is built without a drive controller, +so "the canonical portal arm this method now calls is a no-op here by +construction", and defers the headless committed-portal test as "an open item, +not attempted here given this session's time budget". + +The contract states plainly: "The headless arm reuses the identical Runtime +entry — dual-host parity is a test obligation, not an aspiration (§8)." +Combined with A3, the entire headless production placement flip — the deletion +of `ResynchronizeLocalPlayerForPortalArrival`, the new `portal` parameter, the +new call — has zero behavioural coverage. The only surviving assertion change +is `CenterCount` 3→2. + +--- + +## MINOR + +### A8 — Portal-test cleanup runs at the end of the test body, so the first assertion failure is masked by a teardown throw + +`ConvergePortalHost` is invoked as the last statement of each portal test +(`RuntimeAcceptedPositionDriveControllerTests.cs:1273/1380/1423/1519`). If any +earlier `Assert` throws, cleanup is skipped, `StartedRuntime.Dispose()` throws +during unwinding, and C# `using`/`try…finally` lets the finally-exception +**replace** the in-flight one. + +The implementer diagnosed their own instance of this correctly (claim 1 — +verified: the mechanism is real and the two corrected assertions were genuine +test bugs, since an accepted `TeleportAdvanced` merge rebases the world frame +onto the destination per #283, and `ReconcileAndAcknowledgePortal` does +legitimately send one movement event). But the pattern remains in the shipped +tests, and it is exactly how a genuine host-projection leak would also present +— which is why it is worth removing rather than remembering. + +**Fix direction.** `try { … } finally { ConvergePortalHost(…); }`, or make +`StartedRuntime.Dispose` record non-convergence and assert it explicitly. + +`ConvergePortalHost` itself is otherwise sound: it cannot double-release — +`AcknowledgeHostProjection`'s `TerminalProjected` branch removes the record +(`RuntimeWorldTransitState.cs:343-355`) and a second call returns false — and +it cannot leak, because a forgotten call throws at Dispose. + +### A9 — Two sources for one teleport sequence + +`LocalPlayerTeleportController.cs:613` builds the authority's +`TeleportSequence` from `_transit.ActiveTeleportSequence` (passed in as +`sequence`), while `ClassifyPortalArrival` +(`RuntimeAcceptedPositionDriveController.cs:514-535`) derives its +accepted/prior pair from `destination.TeleportSequence`. They agree today — +`OfferTeleportDestination:490-497` refuses a second destination for an +already-accepted active sequence — but two sources for one fact is the +campaign's "mapping written against one caller's reachable set" shape. + +**Fix direction.** Use `destination.TeleportSequence` in both, or assert +equality at the producer. + +### A10 — Two documentation statements assert behaviour the code does not have + +(a) The new class doc on `LocalPlayerTeleportPlacement` +(`LocalPlayerTeleportController.cs:188-193`) says the sink "snapshots whatever +the entity already holds and writes no pose itself — this is the render +entity's mover". `TryPublishPlace` writes no pose, but the sink's own upstream +call chain does: `RuntimePlacementPresentationSink.TryApply:108` → +`LiveEntityRuntime.TryApplyRuntimePlacementProjection:1301` → +`TryApplyRuntimePlacementPlace:1386-1420`, which performs +`entity.SetPosition(projection.WorldPosition)`, `entity.Rotation = …`, +`entity.ParentCellId = token.ExactCellId`, **and** `RebucketLiveEntity` — all +before `TryPublishPlace` snapshots. The suffix's entity writes and rebucket are +therefore redundant repeats of a mutation the canonical receipt already made. + +This is harmless at runtime today, but it is the "a doc asserting behaviour +the code does not have" class — and the contract carries the same misreading +(§3.4, D-T4, §12.5(b)), so correcting the code comment alone is not enough. + +(b) The plan correction in `docs/plans/2026-08-02-placement-cutover.md` says +"`RuntimeAcceptedPositionDriveController`'s portal arm reading it — was +already live before route 3 (from route 2's shared drive controller)". The +portal arm was added by **this** slice. A correction that itself asserts a +false fact is worse than the line it corrects. + +### A11 — `PhysicsDiagnostics.LocalTeleportHostKind` is a process-global mutable set from a host + +`src/AcDream.Core/Physics/PhysicsDiagnostics.cs` + `HeadlessSessionHost.cs:625`. +Correct under K3/K4 (all sessions in a headless process are headless), and the +doc comment says so — noted only so it is not later mistaken for per-session +state. No action required this slice. + +### A12 — `AddSyntheticIndoorCell` is representative enough to pass the gate, and no more + +The helper registers a `CellPhysics` with an empty `Resolved` polygon +dictionary, one `PortalInfo(0,0,0)`, and a leaf-only BSP root. It is not shaped +to make a specific assertion pass — it mirrors +`RuntimeSetPositionStateTests.AddSyntheticCell` and its only effect is to make +`PhysicsEngine.IsSpawnCellReady` return true for an indoor cell, which is a +genuine fixture gap (a bare `AddLandblock` passes an empty `CellSurface` list, +so indoor destinations parked `DeferredCell` forever). Accepted. + +The caveat: because the destination cell has no geometry, the App-layer tests +prove "the arm returned `Committed`", not "the destination resolved somewhere +sane". That makes A6's missing position assertions more load-bearing, not +less. + +--- + +## Verified correct (checked against source, not taken on report) + +- **P1 (the D-T3 duty map).** `RuntimeSetPositionState.cs:5036-5058` calls + `PhysicsObjUpdate.CommitSetPositionContactTransition` unconditionally inside + the canonical commit, and `CommitSetPositionContactPrefix` + (`PhysicsObjUpdate.cs:153-175`) derives `Contact`/`OnWalkable`/ + `WaterContact` from the placement result's own `InContact`/`OnWalkable`. Not + re-seeding `TransientState` in `CommitCanonicalTeleportFrame` is correct and + is more faithful than `SetPositionCore`'s unconditional + `Contact|OnWalkable|Active` overwrite, exactly as claimed. +- **P3, happy path, both hosts.** `RuntimeEntityObjectEventStream + .PublishPlacement:164-171` → `EnqueueAndDrain` → + `RuntimePlacementProjectionSubscription.OnPlacement:122-150` is + **synchronous**, inside `CommitCanonical`. Headless's placement therefore + commits and its receipt is consumed inside `PrepareDestination`, strictly + before `AcknowledgePortalMaterialized`/`Complete`/`EndTeleport`. The + receipt-past-`EndTeleport` hazard is discharged for the committed path. (The + residual is A2's park.) +- **Trap T7 / route-2 blast radius.** No portal pending reaches + `SettlePending` or `_newestForce`: the three `pending.Portal.Present` guards + at `:763`, `:794`, and `:820` fence every terminal path, and + `SubmitAndResolvePortal` is a genuine sibling of `SubmitAndResolve` rather + than an overload of it. `git diff` shows **zero** expectation changes in any + force-arm test — the contract's §4 item 8 tripwire is clean. +- **Implementer claim 1 (the teardown throw was a test bug).** Mechanism + verified. Both corrected assertions were genuinely wrong for the stated + reasons, and the `using`-finally exception-replacement is real. See A8 for + the residual. +- **Sabotage B's asymmetry.** Verified structurally: + `PortalProducerInvalidAuthority_ArmDoesNotRunAndNothingMutates` builds an + authority with `RevealGeneration: 0`, which fails + `RuntimePortalPlacementAuthority.IsValid` at + `TryExecuteAcceptedPortalArrival:459` — **before** `ClassifyPortalArrival` is + reached. Forcing the classifier to reject cannot change that test's outcome, + so the 4-of-5 asymmetry is exactly what the code shape predicts. Good + evidence. +- **Register bookkeeping.** AD-42's deletion is justified (its last citation + was D2's two-call `Resolve`+`ResolvePlacement`, which is gone); AD-2's + amendment states the deferred-place adaptation, the T8 tolerance, and the + leash-anchor nuance as D-T9 required; the `:2276` stale comment correction + landed; the 2026-07-16 pseudocode `enter_world` correction landed. Row count + 49→48 is consistent. +- **Build.** `dotnet build AcDream.slnx -c Debug` exit 0 at the reviewed tree. + +--- + +## Judgment on the `_pendingDestination` lifetime + +**The cached destination's lifetime is correct. I found no way to Place +against a superseded destination, and the Place-time re-read it replaced +protected against nothing.** + +The reasoning, checked against source: + +1. **The bug was real and total.** `RuntimeWorldTransitState + .TryBeginPortalReveal:159-183` clears `_hasAcceptedDestination` and + `_acceptedDestination` at `:180-181` on success. + `TryGetAcceptedTeleportDestination:522-527` returns `_teleportActive && + _hasAcceptedDestination`. Since `AimDestination` drives + `TryBeginPortalReveal` through `WorldRevealCoordinator.TryBeginPortal` + (`:742`), the slot is empty at every Place edge. The old re-read could + never succeed — every real portal placement would have refused with + `cause=host-token-unavailable`. Not a stale-destination guard; a hard + failure. + +2. **Write/clear is in exact lockstep with `_pendingCell`.** + `_pendingRotation`/`_pendingCell`/`_pendingDestination`/ + `_hasPendingDestination` are written together at `:780-783` and cleared + together at `:801-805` in `ResetTransit`. `_pendingCell != 0u` is itself the + `haveDestination` predicate (`:481`), so the two cannot diverge. + `_pendingDestination.Position.ObjCellId` **is** `_pendingCell` by + construction (`Position position = destination.Position;` at `:715`). + +3. **A second Aim cannot produce a mismatched pair.** Supersession by a new + F751 goes through `OnTeleportStarted` → `ResetTransit(clearSession: false)`, + which clears all four fields and bumps `_lifetimeGeneration`. Supersession + by a second destination on the *same* sequence is impossible: + `OfferTeleportDestination:490-497` returns `false` once `_destinationAccepted` + is set, and `TryBeginPortalReveal` clears only `_hasAcceptedDestination`, + leaving `_destinationAccepted` latched for the life of the reveal. So the + destination is pinned from Aim to terminal, by the transit itself. + +4. **The one torn window fails closed.** `_pendingRevealGeneration` is written + at `:749`, before the `IsCurrentLifetime`/recenter guards at `:750`, + `:757`, `:764`, while the other three are written at `:780-783`. A `false` + return from any of those guards leaves a NEW generation paired with an OLD + cell/destination. Both Place-edge gates then refuse: + `CanPlacePortalDestination(newGen, seq, oldCell)` fails + `IsCurrentPortalDestination`'s `destinationCell == _snapshot.DestinationCell` + check, and `TryRegisterHostProjection(newGen, oldCell)` fails the same + comparison at `RuntimeWorldTransitState.cs:197-209`. Neither can commit a + stale pair. (This tearing predates the slice — `_pendingCell` already had + it; `_pendingDestination` does not worsen it.) + +5. **Terminal clearing.** Commit → `FireLoginComplete` → `ResetTransit`. + Session reset / generation reset → `ResetSession` / `ResetGenerationPresentation` + → `ResetTransit`. Cancellation through `ResetTransit(clearSession: false)`. + A *refused* Place leaves the fields set — but so does `_pendingCell`, and + both Place-edge gates are keyed on the reveal generation, so a retained + value is inert until a new Aim overwrites it or a reset clears it. + +The one thing the caching genuinely costs is test coverage, not correctness: +A6 removed the only assertion that could have distinguished a stale cached +destination from a fresh one. Restore it. + +--- + +## Summary + +| # | Severity | Finding | +|---|---|---| +| A1 | MAJOR | Refused Place does not stop the anim stream; player released into the world unplaced. P4 undischarged. | +| A2 | MAJOR | `DeferredCell` park commits after `EndTeleport`: body/presentation split + unconsumable Place receipt (P3 graphical half, D-T2.4 re-validation missing). | +| A3 | MEDIUM | Headless discards the arm's status; `is not null` guard silently disables placement. | +| A4 | MEDIUM | `route.ZeroVelocity`/`ConstrainPhase`/`TeleportHookPhase` never read; inversions hardcoded; contract sabotage 3 cannot fail. | +| A5 | MEDIUM | §8 item 8 presentation suite missing; local-player shadow invariant unasserted, and a real cache/publish desync sits behind it. | +| A6 | MEDIUM | Superseded-teleport test lost its destination discriminator; two sibling tests weakened, one now contradicts its name. | +| A7 | MEDIUM | Dual-host parity test obligation (§8 item 6) not met; headless flip untested. | +| A8 | MINOR | Portal-test cleanup outside `finally` masks the first failure behind a Dispose throw. | +| A9 | MINOR | Two sources for the teleport sequence (transit vs destination). | +| A10 | MINOR | Class doc and plan correction each assert behaviour the code does not have. | +| A11 | MINOR | Process-global `LocalTeleportHostKind` (accepted, noted). | +| A12 | MINOR | Synthetic indoor cell is geometry-free — fine as a gate, weak as a placement oracle. | diff --git a/docs/research/2026-08-04-c4-route-3-retail-review-round2.md b/docs/research/2026-08-04-c4-route-3-retail-review-round2.md new file mode 100644 index 00000000..3aa0637d --- /dev/null +++ b/docs/research/2026-08-04-c4-route-3-retail-review-round2.md @@ -0,0 +1,434 @@ +# C4 route 3 — retail-conformance review, round 2 (delta) — 2026-08-05 + +**Verdict: FAIL — but a near miss.** All three round-1 MAJOR retail +findings (R1, R3, R8) are genuinely fixed, and the R1 fix is the right +shape for the right retail reason. What blocks is small and cheap: one +unsound "the park committed" inference that reopens R1's failure mode on +a narrow path (**N1**), one approximation shipped with a code comment +instead of the register row the project's binding rule requires +(**R7**), and one transient condition converted into a fatal exception on +the headless endurance path (**N3**). + +**R4/A5 (the presentation suite) explicitly does NOT block** — see §D. +That call changed from round 1 because the A10 correction is true: I +verified the canonical Place receipt, not the suffix, is the render +entity's mover, and both halves of that path already have tests. + +Scope: delta against my round-1 report +(`2026-08-04-c4-route-3-retail-review.md`). Same working tree, HEAD +`cd3129e9`, uncommitted. Review only. + +--- + +## §A — round-1 findings: disposition + +| # | round-1 finding | round-2 status | +|---|---|---| +| R1 | `Place` one-shot; no re-attempt driver; reveal completes with an unplaced body | **FIXED** — §B, and the fix is retail-correct | +| R2 | headless discards the arm's status, acks a materialization that never happened | **FIXED** — §B.3 | +| R3 | probe's `leash` field can never read `armed` | **FIXED** — `Constraint?.IsConstrained` at `RuntimeAcceptedPositionDriveController.cs:838` | +| R4 | App presentation suite missing; assertions weakened | **PARTIALLY closed, does not block** — §D | +| R5 | headless dual-host parity untested | **FIXED** — `HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake` | +| R6 | `enter_world` caller-sweep correction mis-stated the retail record | **FIXED and independently re-verified** — §C.4 | +| R7 | retail's movement refresh is autonomy-gated; acdream's was not | **PARTIALLY fixed — BLOCKS on register discipline** — §C.3 | +| R8 | probe fired only on commit; refusals invisible under the pinned env var | **MOSTLY fixed** — two App-side causes remain invisible, §E.2 | +| R9 | stale "TryBeginPortal (below)" | **FIXED** (`:751` now reads "above") | +| R10 | `AD-131` does not exist | **FIXED** → `AP-131` | +| R11 | probe hardcoded `autorunCancelled: true` | **FIXED** — reads `CancelAutoRun()`'s bool | + +--- + +## §B — the R1 fix: correct, and correct for the right retail reason + +`LocalPlayerTeleportController.cs:527` + +```csharp +bool placementReady = dataReady && TryAdvancePortalCommit(sequence); +... +var (_, events) = _presentation.Tick(deltaSeconds, placementReady); +``` + +Inverting the readiness feed instead of touching the sequencer is the +right call, for a reason worth recording: **it is retail's own shape.** +Retail holds the player in portal space on `CellManager::blocking_for_cells` +and `SmartBox::UseTime` @0x00455410 runs only `CheckPrefetchStatus` until +the destination is usable — the hold lives in the *readiness predicate*, +not in the animation. Feeding the sequencer "the canonical commit has +already happened" rather than "the data is ready" reproduces that hold +without changing a single sequencer timing (stop condition 2 respected). +`TeleportAnimSequencer.cs` is untouched — confirmed by `git diff`. + +### B.1 — arrival ORDER is preserved; Inversion B intact + +Verified by tracing the single tick on which the commit succeeds: + +1. `TryAdvancePortalCommit` → `TryExecuteAcceptedPortalArrival` → + `TryPrepareAndSubmitAuthoredPlacement` → **canonical body commit** + (`CommitCanonical`, retail `SetPositionSimple` @0x005162B0). +2. `ReconcileAndAcknowledgePortal` → `CommitCanonicalTeleportFrame` + (UnStick @0x00514EEE / UnConstrain @0x00514F02 / re-arm @0x0045418A, + velocity zero @0x004541B4, StopCompletely) — **after** the placement. +3. `CancelAutoRun()` + movement refresh — the `PlayerTeleported` + @0x006B32B0 port, **after** the hook tail. +4. Only then does the sequencer see `worldReady=true`, emit `Place`, and + run the presentation suffix (`NotifyTeleported`, camera reset, + reconcile) and `ObserveMaterialized`. + +Retail: `SetPositionSimple` @0x00453924 → `PlayerPositionUpdated` +@0x00453932 → `teleport_hook` @0x004538AE → `PlayerTeleported` +@0x004538B3 → `set_viewer` @0x004538D5. **Same order.** Inversion B +(local hook AFTER placement) holds; the gating did not move it. + +Two sub-order deltas versus retail, both traced and both **unobservable** +— stated so a future reader does not re-derive them: + +- Retail's `ConstrainTo` @0x0045418A and `set_velocity` @0x004541B4 run + in `HandleReceivedPosition` *after* `PlayerPositionUpdated` returns, + i.e. after `SendMovementEvent` and `set_viewer`. acdream runs both + inside `CommitCanonicalTeleportFrame`, before them. Nothing reads the + leash between those points, and `MoveToStatePack` @0x006B4720 packs + `InqRawMotionState` + `m_position` + contact + longjump + timestamps — + **no velocity** — so the outbound bytes are identical either way. +- `NotifyTeleported()` (teleport_hook's TargetManager teardown) now runs + in the presentation suffix, i.e. after `PlayerTeleported`, where retail + runs the whole hook before it. Purely local; no interaction with the + outbound send. + +### B.2 — the refusal path is now genuinely held, and tested with teeth + +`RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears` +(App.Tests) forces a real Runtime `Contention` by taking the entity's +placement token, drives **100 ticks at 0.1 s** — 10 s, roughly 3× the +tunnel's own 2–5 s timing, well past where the pre-fix code fired +`FireLoginComplete` — and asserts the positive facts: body unmoved, +`IsActive`, `Snapshot.Completed == false`, `LoginCompleteCount == 0`. +It then releases the competing operation and asserts the very next tick +commits at the offered destination. That is a real discriminator, not a +negative-only assertion, and it directly kills R1. + +A permanent refusal now holds in the tunnel showing retail's centered +wait cue (`_holdSeconds` accumulates on `!placementReady`) until +supersession or session reset — which is exactly what contract D-T5 +pinned and §4 item 4 requires. + +### B.3 — headless + +`PrepareDestination` now throws if no drive is wired, gates the attempt +on `_collision.IsReady(destination.CellId)`, and returns +`IsCollisionReady: committed`; +`RuntimeLiveEntitySessionController.TryAdvancePortalCompletion` returns +early on `!IsCollisionReady`, retried by `PumpPortalCompletion` from +`HeadlessSessionHost.Tick`. The materialization ack can no longer +precede a placement. R2 closed. + +### B.4 — P3 (no wedgeable portal receipt) is now ESTABLISHED + +Neither review had closed this. `RuntimePlacementProjectionSubscription.OnPlacement` +(`:122-150`) calls `_sink.TryApply(in head)` **synchronously on publish**, +so a portal-carrying Place receipt is applied and acknowledged inside the +commit call, while the transit is still active and +`IsCurrentPlacementAuthority` still holds. This matters most on headless, +where `TryAdvancePortalCompletion` runs `Complete` + `EndTeleport` +synchronously right after the commit and the FIFO is only republished at +the end of the same `Tick` — had delivery been deferred, that receipt +would have failed the portal gate forever and wedged the placement FIFO +for every entity. It does not. **P3 satisfied on both hosts.** + +--- + +## §C — retail verification of the round-2 changes + +### C.1 — A4: the route fields carry retail semantics (with one granularity defect) + +Verified in `RuntimeAuthoritativePositionRouteClassifier.cs:164-171`: + +- `ConstrainAfterRouting => ConstrainPhase is AfterPositionOperation` — + **correct and discriminating.** It separates the three real retail + cases: force (`None`, early return @0x0045409D), local teleport + (`After`, `ConstrainTo` @0x0045418A), local non-teleport (`Before`, + `ConstrainTo` @0x004541EC). The sabotage the contract's §8 item 11 + demanded can now fail as designed. +- `ZeroVelocity` — read directly; retail `set_velocity` @0x004541B4. + +**N4 (LOW) — `RunsTeleportHook` gates too much.** +`RuntimeAcceptedPositionDriveController.cs:795-801` uses +`route.RunsTeleportHook` (`TeleportHookPhase is not None`) to gate the +**entire** `CommitCanonicalTeleportFrame` call. But that method does far +more than retail's `teleport_hook` @0x00514ED0 (CancelMoveTo, UnStick, +StopInterpolating, UnConstrain, TargetManager, report_collision_end): it +also resets the render-lerp anchors, publishes `UpdateCellId`, runs +StopCompletely, resets the input edges, and resets the object clock — +none of which retail conditions on the hook. Retail's `SetPositionInternal` +@0x00515330 does the frame/cell work unconditionally. Today the portal +route always sets the phase, so there is no live effect; but a future +`TeleportHookPhase.None` would silently skip the render-root cell publish +— the doorway-FLAP class. Gate only the `UnStick`/`UnConstrain`/re-arm +block on the hook phase. + +`RunsTeleportHook` also collapses `Before` and `After` into one boolean. +Harmless here because the call site is unconditionally post-commit, but +it means the field cannot express Inversion B by itself. + +### C.2 — the "sequence" plumbing + +`TryExecuteCanonicalPortalPlacementCore` now takes the authority's +`TeleportSequence` from `_pendingDestination` and `Debug.Assert`s it +equals the transit's `ActiveTeleportSequence`. Sound: the two can only +diverge through a bug, and `RuntimeWorldTransitState.OfferTeleportDestination:495-503` +accepts **exactly one** destination per active sequence +(`if (_destinationAccepted) return false;`), so the Aim-time snapshot is +unique per sequence by construction. (This also disposes of a hazard I +went looking for: a second destination cannot supersede within a +sequence, and a new sequence routes through `OnTeleportStarted` → +`ResetTransit`, which clears `_placementCommitted`/`_awaitingDeferredWake` +at `:932`.) + +### C.3 — R7: the autonomy gate — **the approximation is real and needs its register row (BLOCKING)** + +The retail reading is now exactly right, and I verified both halves: + +- `CommandInterpreter::UsePositionFromServer` @0x006B3B40 (pseudo-C + `:699506-699510`) is literally `return this->autonomy_level != 2`. + acdream's `RuntimeCharacterState.UsePositionFromServer` (`:122`) is + `AutonomyLevel != FullAutonomyLevel(2)` — **an exact port.** +- `CommandInterpreter::SendMovementEvent` @0x006B4680 gates on + `this->autonomy_level != 0` (@0x006B46BB, pseudo-C `:700283`). + +So `!UsePositionFromServer` sends at level 2 only; retail sends at levels +1 **and** 2. The implementer's characterisation is accurate, and the +divergence is currently unreachable — `TrySetAutonomyLevel` has **zero +production callers** (only `RuntimeCharacterStateTests`), so +`AutonomyLevel` is always 2 and the two gates agree. Retail's own default +is 2 (`command_line_autonomy_level = 0x2`, pseudo-C `:1088429`). + +That is precisely what an approximation is: correct today, wrong the day +someone wires level 1. CLAUDE.md's register rule is binding and admits no +implementer discretion — *"Any commit that introduces a deviation (an +adaptation, an approximation, a stopgap, a 'retail does X but we…') adds +its register row IN THE SAME COMMIT. … A deviation found without a row is +a bug twice over."* The shipped disposition is a code comment at +`RuntimeAcceptedPositionDriveController.cs:812-824` saying *"not +register-worthy on its own (no user-visible#-labeled symptom yet)"*. A +symptom is not the threshold; a deviation is. Either thread the raw +`AutonomyLevel` through (the exact port, and the constructor already +takes two optional funcs so the marginal cost is one more) or add the +row. A comment is not the register. + +### C.4 — R6: the corrected `enter_world` banner is now accurate + +Re-verified independently against the pseudo-C, not merely re-read: + +- `:93797` @0x004550EC and `:93824` @0x00455095 both sit inside + **`SmartBox::HandleCreateObject` @0x00454C80**. ✓ +- `CObjectMaint::CreateObject` is invoked at @0x00454FD8 *inside* that + function — a callee, not the enclosing scope. ✓ +- @0x004550EC is in the `if (arg3 != this->player_id)` **non-player** + branch; only @0x00455095 follows `SmartBox::init_player` + + `CellManager::ChangePosition` in the player branch. ✓ +- The load-bearing negative still holds: the `Position*` overload + @0x00516310 has exactly those two callers and the `int` overload + @0x00516170 is reached only from @0x00516327 — `enter_world` is not on + the portal path. + +The banner now states all four facts correctly. This citation is safe for +future sessions to cite. + +--- + +## §D — R4/A5: still open, and it does NOT block. Here is why the call changed. + +Round 1 rated this MAJOR on the premise (taken from contract §3.4) that +the sink *"writes no pose"*, making the suffix the render entity's only +mover. **That premise was wrong, and the A10 correction is right.** I +verified `LiveEntityRuntime.TryApplyRuntimePlacementPlace` +(`LiveEntityRuntime.cs:1386-1420`): a `Place` receipt calls +`entity.SetPosition(projection.WorldPosition)`, sets `entity.Rotation`, +sets `entity.ParentCellId = token.ExactCellId`, and calls +`_spatial.RebucketLiveEntity` — `commitPose` defaults true and is passed +`false` only for `WithdrawalRestored` (`:1364-1372`). The canonical +receipt IS the mover; the suffix's writes are redundant repeats, exactly +as the rewritten class comment now says. The comment is verified true. + +With that established, the coverage picture is materially different from +round 1: + +- the sink's Place-receipt render-entity reframe + rebucket — + **covered** (`RuntimePlacementPresentationSinkTests.Place_ReframesAndRebucketsExactSidecarWithoutMutatingRuntimePhysics`); +- the sink's **portal gate discriminating** — **covered** + (`PortalPlace_RequiresExactCurrentTransitHostAndSequence`, which drives + a valid authority and a mismatched sequence); +- the suffix's own entity write + destination bucket ordering — + **covered** (the two `ConcretePlacement_*` tests, adapted to the new + signature against a real `LiveEntityRuntime`/`WorldEntity`/`GpuWorldState`); +- the **canonical body** pose and cell after an App-driven portal commit + — **now covered**: the four weakened assertions were restored against + `harness.Movement.Controller.Position`/`.CellId`, including A6's + superseded-destination discriminator, and the App harness now runs a + real Runtime rather than a fake placement. + +What remains uncovered is narrower than "the presentation suite was not +built": no single test composes teleport-controller → canonical commit → +real sink → suffix; the **local-player collision shadow** after a portal +commit (#312's own layer) is unasserted; the T8 overwrite ordering is +unasserted; and the refused-Place test asserts body/LoginComplete/IsActive +but not the render entity. + +That is a genuine gap and it is #312-adjacent, so it must be recorded and +closed in C5 — the implementer flagged it explicitly rather than claiming +closure, which is the right behaviour. But it is no longer a +"nothing asserts the only mover" hole, and it does not block this slice. + +--- + +## §E — new findings + +### N1 — MEDIUM — `PendingCount == 0` does not mean "the portal committed"; R1's failure mode survives on a narrow path + +`LocalPlayerTeleportController.cs:653-657` + +```csharp +if (_awaitingDeferredWake) +{ + if (_acceptedPositionDrive.PendingCount != 0) + return false; + _awaitingDeferredWake = false; + _placementCommitted = true; // ← inference + return true; +} +``` + +and identically `HeadlessSessionWorldProjection.cs:818` +(`committed = _acceptedPositionDrive.PendingCount == 0;`). + +`PendingCount` is `_pending is null ? 0 : 1` +(`RuntimeAcceptedPositionDriveController.cs:330`) — a shared, arm-agnostic +counter. Three `Advance` paths clear a **portal** pending, and only one +of them commits: + +| `Advance` site | commits? | logs? | +|---|---|---| +| `:918` completed-wake, authority current | yes → `ReconcileAndAcknowledgePortal` | yes | +| `:918` completed-wake, authority stale | body committed by `RetryDeferred`, **suffix skipped** | `AbandonedAtWake` | +| `:973` **watch died** — "most likely a subsequent accepted Position's merge-time Forget" | **no** | **no line at all** | +| `:1020` `!IsPlacementCurrent` on the prepare-retry | **no** | **no line at all** | + +On the last two the caller latches `_placementCommitted = true` for a +placement that never happened: the sequencer is released, `Place` fires, +`ObserveMaterialized` acks a materialization that did not occur, and the +player is revealed at the **origin**. That is exactly R1's shape, +re-entered through the deferred door. + +Reachability is genuinely low — both hosts now gate the attempt behind +collision readiness (`_worldReveal.Evaluate(...).IsReady` graphically, +`_collision.IsReady(...)` headless), so a park is rare — and the +Runtime-layer test `PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale` +proves at least one non-committing convergence exists +(`Assert.Equal(0, drive.PendingCount)` with no reconcile). Nothing tests +the caller's inference against it. + +The fix is small: have the drive report a portal-specific terminal +outcome (it already distinguishes them well enough to log +`AbandonedAtWake`) instead of the caller inferring commit from a shared +counter. Also give the two silent branches a probe line — under D-T8 they +are portal-arrival attempts that ended. + +### N2 — LOW/MEDIUM — headless `IsUnhydratable` is now hardcoded false + +`HeadlessSessionWorldProjection.cs:874` reports `IsUnhydratable: false` +unconditionally, where it previously reported `!ready`. The old value was +itself a conflation (not-yet-resident ≠ unhydratable), so this is not a +regression in meaning — but headless can now never report an +unhydratable destination, so a genuinely unhydratable claim spins in +`PumpPortalCompletion` forever instead of taking AD-2's stated "loud +unhydratable-placement path". Either derive the real predicate or state +in the method's doc that headless does not model it. + +### N3 — MEDIUM — a transient headless condition is now fatal + +`HeadlessSessionWorldProjection.cs:854` throws `InvalidOperationException` +on the `default:` arm, which covers **`NotApplicable`** as well as +`Rejected`. `NotApplicable` is returned by +`TryExecuteAcceptedPortalArrival` for `record.PhysicsBody is null` and +for an active initial-Create residence +(`RuntimeAcceptedPositionDriveController.cs:243-251`) — hydration-race +conditions, not "the reveal is stale". The exception message asserts a +diagnosis ("the reveal itself is stale or the local player has no +canonical body, neither recoverable by waiting") that is true for +`Rejected` and not established for `NotApplicable`. + +Refusing to fake success is right; converting a possibly-transient +condition into a process-killing throw on the host that must survive +K4's 30-session / two-hour endurance profile is the wrong end of that +trade. Split the arm: throw on `Rejected`, treat `NotApplicable` as a +retryable wait with a bounded attempt budget (or a loud log plus +`IsCollisionReady: false`). + +### N4 — LOW — `RunsTeleportHook` over-gates the frame commit (§C.1) + +### N5 — LOW — the plan-doc correction contradicts itself + +`docs/plans/2026-08-02-placement-cutover.md` now opens *"This line was +accurate for both halves at the time it was written"*, explains that +route 3 added the consumer and producer together, and then closes with +*"'Zero producing call sites; the adapter does not exist' was accurate +only for the producer half"* — which contradicts the opening sentence. +The substance is right (the type, `IsValid`, `Pending.Portal`, the sinks' +gates and `BeginAcceptedPlacementCore`'s gate pre-dated route 3; the arm +did not); the paragraph needs one pass so a future reader can cite it. + +--- + +## §E.2 — R8 residual + +`LogPortalArrivalAttempt` now fires on every Runtime-side exit under the +gate's own `ACDREAM_PROBE_LOCAL_TELEPORT`, and a live `Contention` +refusal was observed — R8's substance is closed and **§9's gate is now +passable as specified**: `leash=armed` reads from +`ConstraintManager.IsConstrained`, which `ConstrainTo` @0x00556240 sets +unconditionally, so a committed arrival prints `armed`. + +Two App-side refusal causes still never reach Runtime and therefore emit +no `[local-tp]` line: `cause=stale-reveal` +(`LocalPlayerTeleportController.cs:666`, the `CanPlacePortalDestination` +preflight) and `cause=host-token-unavailable` (`:737`). Both log through +`PhysicsDiagnostics.LogTeleport`, gated by the *different* +`ACDREAM_PROBE_TELEPORT`. Under the pinned gate environment these are +invisible. Either route them through `LogLocalTeleportArrival` or add +`ACDREAM_PROBE_TELEPORT=1` to §9's environment line. + +--- + +## §F — gate evidence + +- **Release build**: green, 0 warnings / 0 errors. +- **`AcDream.Runtime.Tests`**: 1,164 passed / 0 failed / **0 skipped**. +- **`AcDream.Headless.Tests`**: 85 passed / 0 failed / **0 skipped**. +- **`LocalPlayerTeleportControllerTests`**: 21 passed / 0 failed / **0 skipped**. +- No `Skip` attribute in any touched test file. No test weakened: the + four round-1 weakenings were restored with stronger targets (the real + canonical body rather than the deleted fake's captured argument), and + three genuinely new discriminators were added (`RefusedPlace_…`, + `PortalCommitted_UnderServerControlSendsNoMovementEvent`, + `PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale`, + plus the headless park test). +- Per process rule 3, none of this is evidence of correctness — it is + evidence that nothing regressed while the above holes remain. + +--- + +## §G — what must land to pass + +1. **N1** — stop inferring commit from `PendingCount`; report a + portal-specific terminal outcome, and log the two silent + non-committing `Advance` branches. +2. **R7** — add the AD register row for the autonomy approximation, or + thread the raw `AutonomyLevel` through and make it exact. Binding + project rule; not an implementer judgment call. +3. **N3** — do not throw on `NotApplicable`; split it from `Rejected`. + +Cheap follow-ups, non-blocking: N2, N4 (gate only the UnStick/UnConstrain/ +re-arm block on `RunsTeleportHook`), N5, and R8's two App-side causes. + +Record as a dated, named C5 item: R4/A5's residual — the end-to-end +composition test, the local-player collision shadow after a portal +commit, and the T8 overwrite ordering. diff --git a/docs/research/2026-08-04-c4-route-3-retail-review.md b/docs/research/2026-08-04-c4-route-3-retail-review.md new file mode 100644 index 00000000..140d453e --- /dev/null +++ b/docs/research/2026-08-04-c4-route-3-retail-review.md @@ -0,0 +1,515 @@ +# C4 route 3 — retail-conformance review (2026-08-04) + +**Verdict: FAIL.** + +Reviewer scope: the uncommitted working-tree diff at HEAD `cd3129e9` +(`git diff HEAD` + untracked), branch +`claude/acdream-physics-divergence-5aa784`. Review only — no edits made. + +The retail *reading* in this slice is excellent. Every §1 claim in the +pinned contract reproduces line-for-line in +`docs/research/named-retail/acclient_2013_pseudo_c.txt` (§A below), both +inversions are implemented in the right direction, the D-T3 duty map is +complete, and the implementer's P1 finding on TransientState is not just +correct — it retires a real pre-existing divergence. + +The slice fails on **what happens when the placement does not commit**. +`TeleportAnimEvent.Place` is one-shot, so every non-`Committed` outcome +silently skips the placement, the presentation suffix, and the +materialization acknowledgement while the animation stream marches on to +reveal the world and fire LoginComplete anyway. The headless host has the +same hole with the extra property that it *asserts a materialization that +did not happen*. Neither is covered by a test, because the App-layer +presentation suite the contract made mandatory (§8 items 8/9/10, closing +route 2's B2 gap) was not written — the existing App assertions were +weakened instead. And the one probe field the connected gate keys on +(`leash=armed`) can never be true as coded. + +--- + +## Findings + +### R1 — MAJOR — `TeleportAnimEvent.Place` is one-shot; there is no re-attempt driver, and the reveal completes anyway + +`src/AcDream.Core/World/TeleportAnimSequencer.cs:136-141` + +```csharp +case TeleportAnimState.Tunnel: + if (worldReady) + { + evts.Add(TeleportAnimEvent.Place); + Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); +``` + +The state advances in the **same tick** the event is emitted. `Place` +never fires again for that reveal. + +`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:505-522` + +```csharp +case TeleportAnimEvent.Place: + if (!_worldReveal.CanPlacePortalDestination(...)) return; + if (!TryExecuteCanonicalPortalPlacement(sequence)) return; // :513 + ... + _placement.Place(_pendingRotation); // :517 + ... + _worldReveal.ObserveMaterialized(...); // :520 +``` + +`TryExecuteCanonicalPortalPlacement` returns `true` **only** on +`RuntimeAcceptedPositionExecutionStatus.Committed` +(`LocalPlayerTeleportController.cs:597-598`). Every other status — +`Contention`, `Rejected`, `NotApplicable`, and notably `DeferredCell` — +returns `false` and the `Tick` returns. + +Consequences, all reachable: + +- `_placement.Place` never runs → no `entity.SetPosition` / + `ParentCellId` / `RebucketLiveEntity`, no `NotifyTeleported()`, no + camera reset, no `_spatial.Reconcile()`. +- `_worldReveal.ObserveMaterialized` never runs → + `RuntimeWorldTransitState.AcknowledgePortalMaterialized` never fires → + `Materialized` stays false. +- The **next** tick still advances the sequencer: + `TunnelContinue` → `TunnelFadeOut` → `PlayExitSound` + (`RevealWorldViewport`) → `WorldFadeIn` → `FireLoginComplete` + (`_mode.EnterWorld()` + `SendLoginComplete()` + `_worldReveal.Complete()` + + `ResetTransit`). +- `RuntimeWorldTransitState.Complete` then trips + `FailInvariant("portal-complete-before-materialized")` + (`RuntimeWorldTransitState.cs:701-706`) and returns `false`; + `WorldRevealCoordinator.Complete()` (`:268-276`) **discards** that + `false`. `ResetTransit(clearSession:false)` then calls + `_transit.EndTeleport()` + `_worldReveal.Cancel()`, so the ledger + converges — but the reveal is recorded cancelled, not completed, with + one invariant failure logged. + +User-visible outcome on `Contention`/`Rejected`: the player is revealed +into the destination world **standing at the origin position**, with +LoginComplete sent. On `DeferredCell`: the body commits later at the +collision-generation wake (`Advance` → `ReconcileAndAcknowledgePortal`), +but the presentation suffix, camera reset, rebucket, and materialization +ack are gone forever. + +This is exactly what contract §4 items 4 and 5 forbid ("a refused +placement must NOT … must not advance the anim-event stream's terminal +events"; "never a half-state", "never a silent wedge in portal space") +and it is the D-T5/P4 obligation the contract flagged in advance: *"if +the Place anim event is one-shot, the re-attempt must be driven by the +same Tick predicate that produced it, and THAT mechanism must be stated +in the commit."* It is one-shot, and no mechanism was added. + +The comment shipped in its place is false. `LocalPlayerTeleportController.cs:566-572`: + +> "On any refusal this returns `false` without mutating anything — the +> D-T5 refusal shape: … the transit's own cancellation/supersession +> machinery is the authority on what happens next." + +The transit is not the authority on what happens next. The animation +sequencer is, and it does not wait. + +**Correct behaviour:** either re-drive the Place edge from the same +`ready`-gated Tick predicate until it commits (holding the sequencer in +`Tunnel` — which is what retail's `blocking_for_cells` hold is), or +cancel the reveal explicitly on refusal so the player is never revealed +without a committed placement. Retail has no third option: it places +unconditionally and immediately (`SmartBox::TeleportPlayer` @0x00453910) +and only the *simulation* waits on prefetch. + +--- + +### R2 — MAJOR — headless discards the arm's status and acknowledges a materialization that did not happen + +`src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs:775` + +```csharp +_ = _acceptedPositionDrive.TryExecuteAcceptedPortalArrival( + destination, + authority); +``` + +The status is dropped on the floor. `PrepareDestination` then +unconditionally returns a ready readiness report, and +`RuntimeLiveEntitySessionController.TryCompletePortal` continues its +fully-synchronous suffix: `AcknowledgeDestinationReadiness` → +`AcknowledgePortalMaterialized` → `SimulationReleaseProjected` → +`Complete` → `SendGameAction(LoginComplete)` → `EndTeleport`. + +So on any refusal or park, the headless host **fires +`AcknowledgePortalMaterialized` for a placement that never committed** — +contract §4 item 5 and D-T5 rows 1/2 both state in terms that the +materialization ack must fire only from the committed outcome. The bot +reports a completed teleport while standing where it started, with no +log line of any kind (see R8). + +Retail contradiction is indirect but real: retail's +`SmartBox::PlayerPositionUpdated` @0x00453870 clears +`waiting_for_teleport` **inside the same call that performed +`SetPositionSimple`** (@0x00453924 → @0x0045389A). The "wait is over" +edge is downstream of the placement in retail; here it can precede a +placement that never occurred. + +--- + +### R3 — MAJOR — the D-T8 probe's `leash` field can never read `armed`; the connected gate as pinned is unpassable + +`src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs:694` + +```csharp +leashArmed: controller.PositionManager?.IsFullyConstrained() ?? false, +``` + +Retail `ConstraintManager::IsFullyConstrained` @0x005560D0 is +`constraint_distance_max * 0.9 < constraint_pos_offset` — "has strained +past 90 % of the leash", the predicate `jump_is_allowed` reads. It is not +"is the leash armed". The acdream port says so explicitly +(`src/AcDream.Core/Physics/Motion/ConstraintManager.cs:79-89`). + +Retail `ConstraintManager::ConstrainTo` @0x00556240 (pseudo-C +:353528-353537) ends with +`constraint_pos_offset = Position::distance(anchor, physics_obj->m_position)`; +acdream mirrors it at `ConstraintManager.cs:65-71`. Because +`RearmConstraintLeashAtCurrentPosition` +(`PlayerMovementController.cs:1836-1845`) anchors at the body's **own** +`CellPosition`, that distance is 0. `max * 0.9 < 0` is false. + +Therefore every committed portal arrival prints `leash=unarmed`. The +contract's §9 pass criterion — *"Pass requires ALL of … `leash=armed`"* — +cannot be met, and a future reader hitting `leash=unarmed` would chase a +phantom missing leash (the exact 4b-3 A1 defect class the contract +warned about, inverted). + +The correct observable is `ConstraintManager.IsConstrained`, which the +Runtime test itself uses +(`RuntimeAcceptedPositionDriveControllerTests`, +`Assert.True(controller.PositionManager.Constraint!.IsConstrained)`). +`PositionManager` does not currently surface it; it needs to. + +--- + +### R4 — MAJOR — the mandatory App-layer presentation suite is missing, and the existing App assertions were weakened + +`tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs` +gained **zero** new `[Fact]`s. All five new tests in the diff are in +`tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs`. + +What changed in the App file is assertion *strength*, downward: + +```diff +- Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position); ++ Assert.True(harness.Placement.Called); +``` + +(and the same substitution at eight further sites). The interface change +makes the literal old assertion impossible, which is fine — but the +contract required the replacement coverage and named it as load-bearing: + +- §8 item 8: "after a committed portal placement through the REAL sink + + suffix, the render `WorldEntity` position/rotation/`ParentCellId` equal + the resolved body, the draw bucket moved, the local-player shadow + agrees, and the sink's Place receipt was consumed with a VALID portal + authority … **route 2's B2 coverage gap … becomes load-bearing here and + MUST close**." +- §8 item 9: the T8 overwrite ordering (wire pose then resolved pose). +- §8 item 10: refused Place edge presentation. + +None exist. Since `RuntimePlacementPresentationSink.TryPublishPlace` +writes no pose (contract §3.4, re-confirmed), the suffix in +`LocalPlayerTeleportPlacement.Place` is now the render entity's **only** +mover — and nothing asserts it moves the entity to the resolved pose. +Combined with R1 (where that suffix is skipped entirely on refusal), this +is the #312 shape verbatim: process rule 4, "tests must assert the layer +that broke." + +--- + +### R5 — MAJOR — headless dual-host parity (§8 item 6, D-T6) has no coverage, self-declared open + +`tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs:413-422` +(added in this diff): + +> "no accepted-position drive controller is wired into this fixture's +> projection … so the canonical portal arm this method now calls is a +> no-op here by construction (`_acceptedPositionDrive` is null) … a +> headless-host-specific committed-portal test is an open item, not +> attempted here given this session's time budget." + +D2 (`ResynchronizeLocalPlayerForPortalArrival`, ~40 non-comment lines and +AD-42's last citation) was deleted and its replacement has **zero** +headless test coverage. D-T6 pinned this: *"dual-host parity is a test +obligation, not an aspiration."* The honest disclosure is appreciated and +does not change the finding. + +Related, and unremarked in the diff: the deleted method also performed +`controller.LocalEntityId = record.LocalEntityId ?? 0u`. Verified safe — +`RuntimeLocalPlayerPhysicsPublicationState.cs:214` sets it at publication +and the entity key is stable across a portal — but the drop deserves a +line in the commit message. + +--- + +### R6 — MEDIUM — the `enter_world` caller-sweep correction mis-states the retail record it is correcting + +`docs/research/2026-07-16-portal-completion-pseudocode.md` §2.1 banner: + +> "a caller sweep … shows both `CPhysicsObj::enter_world` call sites +> living inside `CObjectMaint::CreateObject`'s player branch +> (`SmartBox::init_player` + `CellManager::ChangePosition` immediately +> precede it)" + +Verified independently. The two call sites are pseudo-C :93797 +(@0x004550EC) and :93824 (@0x00455095). Both live inside +**`SmartBox::HandleCreateObject` @0x00454C80**, not +`CObjectMaint::CreateObject` — the latter is merely a *callee* at +@0x00454FD8 inside that same function. And they are **not both in the +player branch**: @0x004550EC is in the `if (arg3 != this->player_id)` +NON-player branch (`PhysicsDesc::get_position` → `enter_world(var_bc, …)` +for a newly created remote object); only @0x00455095 sits in the player +branch after `init_player` + `ChangePosition`. + +The load-bearing NEGATIVE is **CONFIRMED**: the `Position*` overload +@0x00516310 has exactly those two callers, the `int` overload @0x00516170 +is reached only from @0x00516327, and neither is on the portal path. +`SmartBox::TeleportPlayer` → `SetPositionSimple` is correct. + +But this banner is explicitly a correction to the retail record that +future sessions will cite, and it is wrong in two of its three factual +clauses. Same defect class as "a register row asserting behaviour the +code does not have," applied to a research doc — the exact reason the +contract ordered the correction in-slice. + +--- + +### R7 — MEDIUM — retail's post-teleport movement refresh is autonomy-gated; acdream's is not + +Contract open question (c), answered. +`CommandInterpreter::SendMovementEvent` @0x006B4680 (pseudo-C +:700274-700313): + +``` +if ((player != 0 && this->smartbox != 0) && CPhysicsObj::InqRawMotionState(player) != 0) + if (this->autonomy_level != 0) + MoveToStatePack::MoveToStatePack(...) + SendMoveToStateEvent(...) +``` + +Two gates: a non-null raw motion state, and **`autonomy_level != 0`**. +Under server control retail sends nothing. + +`RuntimeAcceptedPositionDriveController.cs:678-681` calls +`_localPlayerOutbound.TrySendMovement(...)` unconditionally; +`LocalPlayerOutboundController.TrySendMovement:187-229` gates only on a +resolvable outbound position. The controller already holds +`UsePositionFromServer` (retail's `UsePositionFromServer()`), and +`_usePositionFromServer` is already a field on this very class — the +autonomy fact is in scope. + +Everything else about the port checks out: the message family is +`MoveToState` (retail packs `InqRawMotionState` into `MoveToStatePack`), +the contact byte is `Contact && OnWalkable` in both, exactly one is sent, +and no `AutonomousPosition` goes out (retail's teleport branch returns +before `SendPositionEvent` — verified at @0x004541C0). Order is right: +`CommitCanonicalTeleportFrame` (hook tail) → `CancelAutoRun` → +movement send, matching @0x004538AE → @0x004538B3 → tail-jump. + +Either add the autonomy gate or file the delta as a register row. + +--- + +### R8 — MEDIUM — D-T8 emits one line per *committed* arrival, not per attempt; refusals are invisible under the pinned gate environment + +`PhysicsDiagnostics.LogLocalTeleportArrival` is called from exactly one +site, `ReconcileAndAcknowledgePortal` +(`RuntimeAcceptedPositionDriveController.cs:687-696`), reached only on +`CommittedHostAcknowledgementPending`. Its `placementStatus` argument is +the literal `"Committed"`. + +The graphical refusal path logs via `PhysicsDiagnostics.LogTeleport` +(`LocalPlayerTeleportController.cs:582-583`, `:598-599`), which is gated +by **`ACDREAM_PROBE_TELEPORT`** (`PhysicsDiagnostics.cs:1160-1161`) — a +different env var from the `ACDREAM_PROBE_LOCAL_TELEPORT` the gate pins. +The headless refusal path logs nothing at all (R2). + +Net: with the contract's pinned gate environment, a refusal produces zero +output on either host. D-T8 specified "One line per portal-arrival +attempt: cause … placement status", and §9 requires "zero +`Refused`/`Contention` lines in ordinary play" — unobservable as built. +Given R1, an unobserved refusal is precisely the failure that would ship. + +--- + +### R9 — LOW — stale directional reference in a comment added by this diff + +`src/AcDream.App/Streaming/LocalPlayerTeleportController.cs:775`: +"TryBeginPortal (below) drives …". `_worldReveal.TryBeginPortal` is +called **above** this comment, at `:741`, in the same method. Process +rule 6. + +### R10 — LOW — `AD-131` does not exist + +`src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs:2292` +cites "AD-2/AD-131/#275". The AD section has 48 rows. The row is +**AP-131** (`docs/architecture/retail-divergence-register.md:283`), which +is what the contract itself says. Introduced by this diff, in the very +comment the slice rewrote to fix a stale comment. + +### R11 — LOW — the probe asserts more than it observes + +`RuntimeAcceptedPositionDriveController.cs:693-695` hardcodes +`hookTailRan: true` and `autorunCancelled: true`. +`RuntimeLocalPlayerMovementState.CancelAutoRun():226-234` returns `false` +when autorun was already off (correctly mirroring retail's +`SetAutoRun` @0x006B4850, which acts only on a state *change* at +@0x006B4871). The field reports the action ran, not the state changed — +report the returned bool. + +--- + +## §A — retail claims verified independently (do not re-derive) + +All against `docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +| claim | where | result | +|---|---|---| +| `SmartBox::TeleportPlayer` @0x00453910 = `SetPositionSimple(player, dest, 1)` @0x00453924 + `PlayerPositionUpdated(this, 1, FLT_MAX)` @0x00453932, nothing else | :92514-92523 | **CONFIRMED** — the generic path, route 2's exact primitive, third route running | +| `PlayerPositionUpdated` teleport arm order: `position_update_complete=0` @0x00453890, `waiting_for_teleport=0` @0x0045389A, `has_been_teleported=0` @0x004538A4, `teleport_hook` @0x004538AE, `cmdinterp->PlayerTeleported()` @0x004538B3, `set_viewer` @0x004538D5, `LScape::update_viewpoint` @0x004538E2, `CellManager::ChangePosition` @0x00453903 | :92469-92509 | **CONFIRMED**, exactly the contract's order | +| `CommandInterpreter::PlayerTeleported` @0x006B32B0 = `SetAutoRun(0,1)` + tail-jump `SendMovementEvent` | :699036-699041 | **CONFIRMED**. New: `SetAutoRun` @0x006B4850 only acts when `(arg2==0) != (auto_run==0)` (@0x006B4871) — acdream's `CancelAutoRun` early-return matches | +| **Inversion A** — local TELEPORT branch @0x0045415F: `TeleportPlayer(&var_48)` @0x00454168 → `ConstrainTo(arg2, &var_48, start, max)` @0x0045418A → `set_velocity(player, {0,0,0}, 1)` @0x004541B4 → return | :93013-93023 | **CONFIRMED**, including the WIRE-destination anchor | +| FORCE_POSITION branch returns @0x0045409D before every `ConstrainTo` | :92925-92933 | **CONFIRMED** — route 2's no-re-arm rule intact and correctly left force-scoped | +| **Inversion B** — the local hook runs AFTER the placement (from `PlayerPositionUpdated`), opposite to the remote arm's @0x005163EF | :92497 vs 4b-3's citation | **CONFIRMED** | +| `enter_world` is NOT on the portal path | :93797, :93824 | **NEGATIVE CONFIRMED** — but the attribution in the new correction banner is wrong; see R6 | +| **P1 (TransientState not re-seeded)** — retail `CPhysicsObj::SetPositionInternal(CTransition*)` @0x00515330 derives Contact from `collision_info.contact_plane_valid` @0x00515430, WaterContact from `contact_plane_is_water` @0x00515453, OnWalkable from `set_on_walkable(contact_plane.N.z vs floor_z)` @0x00515467-0x0051548E, Sliding from `sliding_normal_valid` @0x005154E1. **No unconditional `Contact\|OnWalkable` seed anywhere.** | :283484-283519 | **THE FINDING IS CORRECT AND IS A FIDELITY GAIN.** `PhysicsObjUpdate.CommitSetPositionContactPrefix` (`src/AcDream.Core/Physics/PhysicsObjUpdate.cs:154-176`) is that exact port, and runs inside the canonical commit (`RuntimeSetPositionState.cs:5038`). The old `SetPositionCore` seed (`PlayerMovementController.cs:1859-1862`) was the divergence; dropping it is right. The `Active` argument also holds — `PlayerMovementController.cs:2069` and `:2449` re-set it every frame. | +| `ConstraintManager::ConstrainTo` @0x00556240 initializes `constraint_pos_offset = distance(anchor, m_position)` | :353528-353537 | **CONFIRMED** — acdream matches; also the basis for R3 | +| `CommandInterpreter::SendMovementEvent` @0x006B4680 is autonomy-gated | :700274-700313 | **CONFIRMED** — see R7 | + +## §B — implementation facts verified correct + +- **D-T3 duty map (P1) is complete.** All nine `SetPositionCore` duties + land in `CommitCanonicalTeleportFrame` + (`PlayerMovementController.cs:1993-2041`) or the canonical commit, in + `SetPositionCore`'s own order (StopCompletely → input/mouse resets → + UnStick/UnConstrain/re-arm → edge resets → clock reset). Nothing + silently dropped except the TransientState seed, which is correct + (§A). +- **Inversions implemented in the right directions.** The portal arm + consumes the classifier's dormant LocalPlayer-teleport branch + (`RuntimeAuthoritativePositionRouteClassifier.cs:336-356`) unchanged; + `ConstrainPhase.AfterPositionOperation` + `ZeroVelocity: true` + + `SendPositionImmediately: false` + `TeleportHookPhase.AfterPositionOperation` + all flow through. The hook tail runs only from the committed receipt. +- **The force arm is untouched.** The only route-2 edits are one + force-scoping doc sentence (`:133-137`) and a non-`required` + `Portal { get; init; }` on `Pending` that defaults empty. Zero route-2 + test expectation changes — §4 item 8's tripwire is clean. +- **The synthetic `priorTeleport = accepted - 1`** in + `ClassifyPortalArrival` is sound: `TeleportAdvanced` reads only the + boolean `PhysicsTimestampGate.IsNewer(prev, accepted)`, the branch's + resulting route does not depend on the previous stamp's magnitude, and + wrap is safe at `accepted == 0`. +- **AD-42 deleted** (row gone, header 49 → 48 rows), **AD-2 amended in + place** with the deferred-place timing, the T8 tolerated-overwrite + note, and the leash-anchor nuance. Plan gap-line correction present. + Register rules satisfied. +- **Release build green.** Focused + `RuntimeAcceptedPositionDriveControllerTests`: 22 passed / 0 failed / + **0 skipped**. No `Skip` attribute remains in any of the four touched + test files, and no Runtime test was weakened — the five new ones are + strong (the happy path pre-arms the leash at a *stale* anchor so a + missing re-arm fails the `ConstraintPos` assertion; the refusal tests + assert positive "nothing moved, transit still active, no packets" + facts). + +## §C — the production bug fix: correct, complete, no stale-destination hazard + +Verified. `RuntimeWorldTransitState.TryBeginPortalReveal:159-182` sets +`_hasAcceptedDestination = false` and `_acceptedDestination = default` +the instant it claims the generation, and +`TryGetAcceptedTeleportDestination:522-527` returns +`_teleportActive && _hasAcceptedDestination`. So the pre-fix Place-time +re-read was **guaranteed** to fail — the canonical portal arm was 100 % +dead code, refusing with `cause=host-token-unavailable` before ever +reaching Runtime. The diagnosis is right and this was a real production +bug, not a fixture artifact. + +The fix is the right lifetime: + +- `_pendingDestination` is written in `AimDestination:781-784`, in the + same statement block as `_pendingCell`/`_pendingRotation`, only after + `TryBeginPortal` succeeded (`:741-748`) — so the four Aim-time + snapshots are mutually consistent by construction. +- **Cancellation:** `ResetTransit:801-804` clears all four; every + cancellation path funnels through it. +- **Supersession:** a second accepted destination re-enters + `TryAimAcceptedDestination` → `AimDestination` → + `WorldRevealCoordinator.TryBeginPortal` → `WithdrawHostForReplacement` + + a **new** generation, overwriting all four snapshots together. A + superseded destination cannot survive. +- **Staleness at Place:** three independent gates still validate — + `CanPlacePortalDestination(_pendingRevealGeneration, sequence, _pendingCell)` + (`:507-511`), the idempotent + `TryRegisterHostProjection` re-derivation (generation == + `_snapshot.Generation`, cell == `_snapshot.DestinationCell`, + `!Cancelled`, `!Completed` — `RuntimeWorldTransitState.cs:189-227`), + and `BeginAcceptedPlacementCore`'s own + `portal.Projection.DestinationCell == acceptedPosition.LandblockId` + against the **latest merged** snapshot + (`RuntimeSetPositionState.cs:1528-1534`). + +The re-read was protecting nothing. `WorldRevealCoordinator.BeginHostLifetime` +throws if the Aim-time registration fails, so the Place-time +re-derivation is genuinely idempotent and can never mint a second host +projection in production. + +## §D — contract open questions, answered + +**(b) leash anchor — keep the resolved anchor as shipped.** Retail's +`constraint_pos` is write-only (never read by `adjust_offset`, confirmed +in the port's own doc at `ConstraintManager.cs:41-44` and against ACE); +the only downstream consumer of `ConstrainTo`'s inputs is +`constraint_pos_offset = distance(anchor, m_position)`, which is the +placement adjustment (centimetres) in retail and exactly 0 in acdream. +Both are orders of magnitude inside the `0.9 * max` band, so no behaviour +in the leash's brake taper can distinguish them. The AD-2 note is the +right disposition; do **not** switch anchors. + +**(c) `SendMovementEvent` shape — see R7.** Message family, contact +derivation, count, and ordering are all correct; the missing +`autonomy_level` gate is the one delta. + +## §E — errors in the contract itself + +1. **§4 item 3 / D-T5's re-attempt reasoning is the proximate cause of + R1.** D-T5 offered "the anim event re-fires while `ready` holds" as + the leading case and demoted the one-shot case to a parenthetical + verify-and-state. It is one-shot. The contract should have read the + sequencer before writing the row and pinned the driver. This is + process rule 1 ("the contract causes the defect") recurring for the + third documented time. +2. **§9's `leash=armed` criterion is unachievable** with any + `IsFullyConstrained`-shaped observable; the contract should have named + `ConstraintManager.IsConstrained`. See R3. +3. **§3.5 overstates the change:** "`AcknowledgePortalMaterialized` fires + from the committed placement receipt instead of rubber-stamping after + a host mutation." As built it still fires from the host Place edge, + merely gated on a committed status. Substantively equivalent on the + commit path; wording should be corrected so a future reader does not + look for a receipt-driven ack that does not exist. +4. **§1's `enter_world` row** carries the same wrong caller attribution + the banner does (R6) — it says "its local-player caller is the initial + login path only (@0x00455095)", which is right about that site but + silently drops the *other* site @0x004550EC and mis-names the + enclosing function in the derived correction. + +## What must land before this can pass + +1. A re-attempt (or explicit-cancel) mechanism for a non-`Committed` + Place edge, with the driver named and tested — R1. +2. Headless must consume the arm's status and must not acknowledge a + materialization for a placement that did not commit — R2. +3. Fix the probe's `leash` observable (and emit a line per *attempt*, + under the gate's own env var) — R3, R8. +4. Write the App-layer presentation suite (§8 items 8/9/10) closing route + 2's B2 gap, plus one headless committed-portal test — R4, R5. +5. Correct the `enter_world` caller-sweep banner — R6. +6. Gate the movement refresh on autonomy, or file the register row — R7. +7. Comment/citation cleanups — R9, R10, R11. diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index b665fc51..ec4619b9 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -586,7 +586,21 @@ internal sealed class SessionPlayerCompositionPhase () => d.PlayerIdentity.ServerGuid, () => d.PlayerController.Controller, () => d.Character.UsePositionFromServer, - () => liveSessionSource.CurrentSession); + () => liveSessionSource.CurrentSession, + // C4 route 3: the portal arm's PlayerTeleported port needs the + // J5.4 autorun latch owner, one level above the raw controller. + () => d.PlayerController, + // A2/D-T2.4 review fix (2026-08-05): the SAME idempotent query + // the Place edge itself uses (WorldRevealCoordinator. + // CanPlacePortalDestination -> RuntimeWorldTransitState) lets a + // DeferredCell wake re-validate before reconciling instead of + // running the ack suffix against a reveal that ended or was + // superseded while the park sat outstanding. + isPortalAuthorityCurrent: portal => live.WorldTransit + .CanPlacePortalDestination( + portal.RevealGeneration, + portal.TeleportSequence, + portal.Projection.DestinationCell)); // C4 route 4b-2 (2026-08-04): the graphical remote-placement drive // controller — route 4b-1's dormant owner, now driven by the remote // far snap. Its service window is the graphical host's near-tier @@ -886,16 +900,17 @@ internal sealed class SessionPlayerCompositionPhase live.WorldTransit, worldReveal, new LocalPlayerTeleportPlacement( - d.PhysicsEngine, live.LiveEntities, d.PlayerIdentity, d.PlayerController, d.PlayerHost, d.ChaseCameraInput, - d.WorldOrigin, liveSpatialReconciler), new LocalPlayerTeleportSession(liveSessionSource), - presentation); + presentation, + // C4 route 3: the portal arm shares route 2's Runtime + // SetPosition drive controller. + acceptedPositionDrive); LocalPlayerTeleportController CreateLocalTeleportWithTunnel( PortalTunnelPresentation portalTunnel) => diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 63c36e64..040b11f1 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -2273,13 +2273,28 @@ internal sealed class LiveEntityNetworkUpdateController // Position resolves to NoPositionOperation (retail's airborne // no-op — writes nothing at all) or Interpolate (retail's near // InterpolateTo queue — no direct body write here) never receives - // the generic write below. The local player never reaches this - // generic-remote code path at all. C4 route 4b-2 routes the >=96 m - // far snap and C4 route 4b-3 routes the teleport/cell-less - // classification through the canonical Runtime placement owner + // the generic write below. C4 route 4b-2 routes the >=96 m far snap + // and C4 route 4b-3 routes the teleport/cell-less classification + // through the canonical Runtime placement owner // (ApplyRemoteContactRouting); a rejected authority or payload, and // "no classification at all", take the stated UnroutedCatchUp // policy (RuntimeRemoteFarSnapPosition.ResolveArm). + // + // CORRECTED 2026-08-04 (C4 route 3, process rule 6 — this comment + // used to claim "the local player never reaches this generic-remote + // code path at all", which is FALSE): for the local player, + // `earlyRemoteRoute` is null and `OwnsSteadyState(null)` is false + // (RuntimeRemoteSteadyStatePosition.cs — both pattern matches fail + // on null), so every accepted local Apply — including the portal + // DESTINATION Position itself — reaches and runs the generic write + // + rebucket below, writing the raw wire pose onto the local + // player's WorldEntity while portal space still covers the + // viewport. This is pre-existing, tolerated (AD-2/AP-131/#275 + // territory — see the D-T7 discussion in + // docs/research/2026-08-04-c4-route-3-contract.md), and overwritten + // by the committed portal Place receipt's presentation suffix + // (LocalPlayerTeleportPlacement.Place) once the canonical Runtime + // commit lands. Route 3 does not suppress it. TryApplyGenericRemoteRenderPose( entity, diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index fc44888b..c9d86758 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -10,6 +10,8 @@ using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.World; using AcDream.Runtime; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using AcDream.Runtime.World; namespace AcDream.App.Streaming; @@ -173,68 +175,67 @@ internal sealed class LocalPlayerTeleportStreamingOperations internal interface ILocalPlayerTeleportPlacement { - void Place(Vector3 position, uint cellId, Quaternion rotation); + void Place(Quaternion rotation); } /// -/// Commits the local player's deferred portal arrival. It owns the exact -/// Place -> root/controller/camera mutation -> spatial reconcile edge. +/// C4 route 3: acknowledges the local player's deferred portal arrival — +/// the canonical placement itself now runs through +/// +/// (a portal arm sharing route 2's Runtime SetPosition owner), retiring the +/// duplicate Resolve/SetPosition authority this class used to own (D1; +/// docs/research/2026-08-04-c4-route-3-contract.md D-T4). This class runs +/// AFTER that commit succeeds. A10 review fix (2026-08-05): the render +/// pose write and rebucket this method performs +/// are REDUNDANT repeats of a mutation the canonical Place receipt already +/// made — RuntimePlacementPresentationSink.TryApply → +/// LiveEntityRuntime.TryApplyRuntimePlacementPlace already calls +/// entity.SetPosition/sets Rotation/ParentCellId and +/// rebuckets, synchronously, before TryPublishPlace's OWN snapshot +/// even runs (proof obligation P2's ordering). This method's writes are +/// therefore harmless-but-duplicate, not the render entity's ONLY mover as +/// an earlier revision of this comment claimed; kept because they cost +/// nothing extra and this is also where the retail teleport_hook tail's +/// remaining local-player-visible actions run (target-watcher +/// notification, camera reset, spatial reconcile). +/// is the retained accepted destination's wire +/// orientation — the resolved body orientation was already committed +/// identically by CommitCanonical (retail's teleport branch does not +/// independently reorient the mover), so re-deriving it here would only add +/// a second copy of the same source of truth. /// internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement { - private readonly PhysicsEngine _physics; private readonly LiveEntityRuntime _liveEntities; private readonly ILocalPlayerIdentitySource _identity; private readonly IRuntimeLocalPlayerControllerSource _controller; private readonly ILocalPlayerPhysicsHostSource _host; private readonly ChaseCameraInputState _cameras; - private readonly LiveWorldOriginState _origin; private readonly ILiveSpatialReconcilePhase _spatial; public LocalPlayerTeleportPlacement( - PhysicsEngine physics, LiveEntityRuntime liveEntities, ILocalPlayerIdentitySource identity, IRuntimeLocalPlayerControllerSource controller, ILocalPlayerPhysicsHostSource host, ChaseCameraInputState cameras, - LiveWorldOriginState origin, ILiveSpatialReconcilePhase spatial) { - _physics = physics ?? throw new ArgumentNullException(nameof(physics)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _controller = controller ?? throw new ArgumentNullException(nameof(controller)); _host = host ?? throw new ArgumentNullException(nameof(host)); _cameras = cameras ?? throw new ArgumentNullException(nameof(cameras)); - _origin = origin ?? throw new ArgumentNullException(nameof(origin)); _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); } - public void Place(Vector3 position, uint cellId, Quaternion rotation) + public void Place(Quaternion rotation) { PlayerMovementController controller = _controller.Controller ?? throw new InvalidOperationException( "Teleport Place ran without the local player controller."); - var resolved = _physics.Resolve( - position, - cellId, - Vector3.Zero, - controller.StepUpHeight); - var snapped = new Vector3( - resolved.Position.X, - resolved.Position.Y, - resolved.Position.Z); uint playerGuid = _identity.ServerGuid; - controller.SetPosition( - snapped, - resolved.CellId, - CellLocalForSeed(snapped, resolved.CellId)); - - // SnapToCell owns the retail Position frame and may normalize an - // outdoor land-cell index from the cell-local origin. Publish that - // canonical result, not the pre-snap resolver hint, to rendering. if (_liveEntities.TryGetWorldEntity( playerGuid, out WorldEntity? entity)) @@ -245,12 +246,12 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme // Retail CPhysicsObj::enter_world installs the object in its // destination CObjCell before hidden scripts/particles resume. - // The accepted Position packet has already advanced FullCellId, - // but that wire fact alone does not move acdream's retained - // projection out of its source/pending GPU bucket. Commit both - // halves of the spatial move here, while portal space still owns - // the viewport, so CPhysicsObj::update_object's cell-gated tail - // can advance the Hidden/UnHide PES chain at retail's boundary. + // The canonical commit has already advanced FullCellId, but that + // fact alone does not move acdream's retained projection out of + // its source/pending GPU bucket. Commit both halves of the + // spatial move here, while portal space still owns the + // viewport, so CPhysicsObj::update_object's cell-gated tail can + // advance the Hidden/UnHide PES chain at retail's boundary. if (!_liveEntities.RebucketLiveEntity(playerGuid, controller.CellId)) { throw new InvalidOperationException( @@ -260,9 +261,13 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme } // Retail teleport_hook tail @ 0x00514ED0 clears the local target and - // notifies every watcher that this object teleported. + // notifies every watcher that this object teleported. The body's + // constraint leash re-arm and orientation are already the canonical + // commit's job (RuntimeAcceptedPositionDriveController + // .ReconcileAndAcknowledgePortal -> PlayerMovementController + // .CommitCanonicalTeleportFrame), so this suffix only acknowledges + // the result into presentation. _host.Host?.NotifyTeleported(); - controller.SetBodyOrientation(rotation); _cameras.Legacy?.Update(controller.Position, controller.Yaw); _cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw); @@ -276,17 +281,6 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme $"live: teleport materialized - snapped to {controller.Position} " + $"cell=0x{controller.CellId:X8}"); } - - 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; - } } internal interface ILocalPlayerTeleportSession @@ -397,11 +391,28 @@ internal sealed class LocalPlayerTeleportController private readonly ILocalPlayerTeleportPlacement _placement; private readonly ILocalPlayerTeleportSession _session; private readonly ILocalPlayerTeleportPresentation _presentation; + private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive; - private Vector3 _pendingPosition; private uint _pendingCell; private Quaternion _pendingRotation = Quaternion.Identity; private long _pendingRevealGeneration; + private RuntimeTeleportDestination _pendingDestination; + private bool _hasPendingDestination; + + /// + /// A1 review fix (2026-08-05): true once the canonical Runtime commit + /// for THIS teleport lifetime has actually happened. See the class doc + /// on for why this exists. + /// + private bool _placementCommitted; + + /// + /// A1 review fix: true while a DeferredCell park is outstanding + /// for the local player's one possible pending operation. See + /// . + /// + private bool _awaitingDeferredWake; + private float _holdSeconds; private long _lifetimeGeneration; private bool _disposed; @@ -415,7 +426,8 @@ internal sealed class LocalPlayerTeleportController WorldRevealCoordinator worldReveal, ILocalPlayerTeleportPlacement placement, ILocalPlayerTeleportSession session, - ILocalPlayerTeleportPresentation presentation) + ILocalPlayerTeleportPresentation presentation, + RuntimeAcceptedPositionDriveController acceptedPositionDrive) { _authority = authority ?? throw new ArgumentNullException(nameof(authority)); _input = input ?? throw new ArgumentNullException(nameof(input)); @@ -426,6 +438,8 @@ internal sealed class LocalPlayerTeleportController _placement = placement ?? throw new ArgumentNullException(nameof(placement)); _session = session ?? throw new ArgumentNullException(nameof(session)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); + _acceptedPositionDrive = acceptedPositionDrive + ?? throw new ArgumentNullException(nameof(acceptedPositionDrive)); } public bool IsActive => _transit.IsTeleportActive; @@ -488,21 +502,41 @@ internal sealed class LocalPlayerTeleportController bool haveDestination = _pendingCell != 0u; bool originReady = !_streaming.IsRecenterPending; - bool ready = haveDestination + bool dataReady = haveDestination && originReady && _worldReveal.Evaluate(_pendingCell).IsReady; if (!IsCurrentLifetime(generation, sequence)) return; - if (haveDestination && !ready) + // A1 review fix (2026-08-05, retail/architecture review): the + // sequencer's Tunnel -> TunnelContinue transition + // (TeleportAnimSequencer.cs:134-141) is unconditional and + // irreversible the instant it observes `worldReady` true; by the + // time a failed placement is discovered the stream has already left + // Tunnel with no path back, and TeleportAnimSequencer itself is + // untouched (stop condition 2 forbids sequencer timing changes). So + // the boolean fed into the sequencer must never mean "the data is + // ready" alone - it must mean "the canonical Runtime commit has + // ALREADY happened", checked/attempted fresh every tick via + // TryAdvancePortalCommit. This makes D-T5 row 2's "the NEXT Tick + // re-attempts the Place edge" real: the sequencer simply never + // leaves Tunnel while the commit keeps refusing, and by the time it + // finally does leave Tunnel and fire Place, TryAdvancePortalCommit + // has already made the Runtime side succeed - the Place-event + // handler below only ever runs the presentation suffix. + bool placementReady = dataReady && TryAdvancePortalCommit(sequence); + if (!IsCurrentLifetime(generation, sequence)) + return; + + if (haveDestination && !placementReady) _holdSeconds += deltaSeconds; _presentation.SetWaitCue( haveDestination - && !ready + && !placementReady && _worldReveal.ObserveWait( TimeSpan.FromSeconds(_holdSeconds))); - var (_, events) = _presentation.Tick(deltaSeconds, ready); + var (_, events) = _presentation.Tick(deltaSeconds, placementReady); if (!IsCurrentLifetime(generation, sequence)) return; @@ -511,17 +545,32 @@ internal sealed class LocalPlayerTeleportController switch (teleportEvent) { case TeleportAnimEvent.Place: + // TryAdvancePortalCommit above is the only path that + // makes `placementReady` (and, with the REAL sequencer, + // this event) true, so the canonical Runtime commit has + // ALREADY succeeded by construction on that path - this + // only runs the presentation suffix (D-T4). The + // _placementCommitted re-check stays defensive: it is + // the exact same shape of guard IsCurrentLifetime below + // already applies to every other step of this case, for + // a transit that goes stale between the gate above and + // this line being reached. + if (!_placementCommitted) + return; + // B7 review fix (2026-08-05): re-derived, not assumed - + // if the reveal was cancelled/superseded in the window + // between the commit above and this event being + // processed, ObserveMaterialized below would refuse but + // Place/the presentation suffix would already have run + // against a reveal that is no longer current. Same + // check TryExecuteCanonicalPortalPlacementCore itself + // gates on; idempotent to repeat here. if (!_worldReveal.CanPlacePortalDestination( - _pendingRevealGeneration, - sequence, - _pendingCell)) + _pendingRevealGeneration, sequence, _pendingCell)) { return; } - _placement.Place( - _pendingPosition, - _pendingCell, - _pendingRotation); + _placement.Place(_pendingRotation); if (!IsCurrentLifetime(generation, sequence)) return; _worldReveal.ObserveMaterialized( @@ -569,6 +618,193 @@ internal sealed class LocalPlayerTeleportController _presentation.TickTunnel(deltaSeconds); } + /// + /// A1 review fix (2026-08-05): the one gate that decides whether the + /// anim sequencer is allowed to see worldReady=true. Returns + /// ONLY once the canonical Runtime commit for + /// THIS teleport lifetime has actually happened — never speculatively, + /// never optimistically. Three states: + /// + /// + /// Already committed + /// () — returns + /// immediately, every subsequent Tick. + /// A DeferredCell park is outstanding + /// () — polls + /// + /// only to decide whether to re-attempt the Runtime call: Runtime's own + /// Begin would just refuse a second overlapping attempt with + /// Contention while a park is outstanding (the drive tracks at + /// most one pending operation for the local player), so retrying blind + /// would only add noise. B1 review fix (2026-08-05): once + /// PendingCount returns to 0 the park is DONE, but "done" is not + /// "committed" — the drive's own doc names a merge-time Forget + /// (an ordinary ACE broadcast arriving mid-park) as the EXPECTED way a + /// park resolves without committing, and A2's own abandon branches are + /// a second way. The OLD code inferred commit from the empty slot + /// alone; this now asks + /// , + /// which the drive latches ONLY inside a REAL + /// ReconcileAndAcknowledgePortal call, keyed to this exact + /// reveal generation/sequence. A "no" here is NOT a failure — it just + /// means the park ended without placing, so + /// clears and the method falls through to a fresh attempt below, + /// safely (nothing is pending anymore). + /// Neither — attempt the canonical placement fresh + /// this tick. Committed latches + /// ; DeferredCell latches + /// ; every other status (Contention, + /// Rejected, NotApplicable, or the transit no longer owning this + /// reveal) is the D-T5 refusal shape — nothing mutates, and the SAME + /// predicate retries automatically on the NEXT Tick, which is what + /// makes D-T5 row 2's "the next Tick re-attempts the Place edge" + /// mechanism real without ever touching + /// . + /// + /// + private bool TryAdvancePortalCommit(ushort sequence) + { + if (_placementCommitted) + return true; + + if (_awaitingDeferredWake) + { + if (_acceptedPositionDrive.PendingCount != 0) + return false; + _awaitingDeferredWake = false; + if (_acceptedPositionDrive.TryConsumePortalCommit( + _pendingRevealGeneration, sequence)) + { + _placementCommitted = true; + return true; + } + // The park ended without placing (Forgotten, or abandoned by + // A2's re-validation). Fall through to the fresh-attempt path + // below in this SAME call — nothing is pending, so it is safe. + } + + if (!_worldReveal.CanPlacePortalDestination( + _pendingRevealGeneration, + sequence, + _pendingCell)) + { + PhysicsDiagnostics.LogTeleport( + "REFUSED", _pendingCell, "cause=stale-reveal"); + // R8 residual fix (2026-08-05): this refusal previously only + // logged through PhysicsDiagnostics.LogTeleport, gated by the + // DIFFERENT ACDREAM_PROBE_TELEPORT flag — invisible under + // ACDREAM_PROBE_LOCAL_TELEPORT, the gate the rest of this + // route's arrival/commit lines use. No placement was attempted, + // so there is no resolved cell/leash/autorun fact to report. + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "stale-reveal", + placementStatus: "Refused", + portalGeneration: _pendingRevealGeneration, + teleportSequence: sequence, + destinationCell: _pendingCell, + resolvedCell: 0u, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return false; + } + + RuntimeAcceptedPositionExecutionStatus status = + TryExecuteCanonicalPortalPlacementCore(sequence); + switch (status) + { + case RuntimeAcceptedPositionExecutionStatus.Committed: + _placementCommitted = true; + return true; + case RuntimeAcceptedPositionExecutionStatus.DeferredCell: + _awaitingDeferredWake = true; + return false; + default: + return false; + } + } + + /// + /// C4 route 3 (D-T1/D-T2): builds the producer's + /// from live transit facts + /// and drives the canonical Runtime portal arm. No new + /// exposure is needed — the host + /// token is RE-DERIVED through the transit owner's idempotent + /// TryRegisterHostProjection (the same generation+cell returns + /// the token already + /// registered at Aim time; a stale generation, wrong cell, cancelled, or + /// completed reveal refuses), which makes a superseded token unobtainable + /// by construction. + /// + /// + /// The destination itself is — the + /// value captured at Aim time — and NOT a + /// fresh _transit.TryGetAcceptedTeleportDestination read. + /// atomically + /// CONSUMES the transit's one accepted-destination slot the instant Aim + /// claims the reveal generation (it clears + /// _hasAcceptedDestination so a stale destination can never be + /// re-claimed by a later portal) — by Place time that slot is already + /// empty, so re-querying it here always fails. This mirrors why + /// // + /// are themselves Aim-time + /// snapshots rather than live transit reads. + /// + /// + /// + /// A9 review fix (2026-08-05): is + /// 's OWN + /// , not the + /// transit's separately-tracked ActiveTeleportSequence the caller + /// otherwise threads through — one source for the fact this method's + /// authority carries, asserted equal to the caller's copy so the two + /// can never silently diverge. + /// + /// + private RuntimeAcceptedPositionExecutionStatus + TryExecuteCanonicalPortalPlacementCore(ushort sequence) + { + System.Diagnostics.Debug.Assert( + !_hasPendingDestination + || _pendingDestination.TeleportSequence == sequence, + "The transit's active sequence and the Aim-time destination's " + + "own sequence must never diverge (A9)."); + if (!_hasPendingDestination + || !_transit.TryRegisterHostProjection( + _pendingRevealGeneration, + _pendingCell, + out RuntimeWorldHostProjectionToken hostToken)) + { + PhysicsDiagnostics.LogTeleport( + "REFUSED", _pendingCell, "cause=host-token-unavailable"); + // R8 residual fix (2026-08-05): same rationale as the + // stale-reveal refusal above — route through + // LogLocalTeleportArrival too, so ACDREAM_PROBE_LOCAL_TELEPORT + // alone is enough to see every App-side refusal cause. + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "host-token-unavailable", + placementStatus: "Refused", + portalGeneration: _pendingRevealGeneration, + teleportSequence: sequence, + destinationCell: _pendingCell, + resolvedCell: 0u, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + + RuntimeTeleportDestination destination = _pendingDestination; + var portal = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: _pendingRevealGeneration, + TeleportSequence: destination.TeleportSequence, + Projection: hostToken); + return _acceptedPositionDrive.TryExecuteAcceptedPortalArrival( + destination, + portal); + } + public void ResetSession() { ThrowIfDisposed(); @@ -695,7 +931,6 @@ internal sealed class LocalPlayerTeleportController if (!IsCurrentLifetime(generation, sequence)) return false; - Vector3 worldPosition; if (transition.ChangesStreamingCenter) { bool isSealedDungeon = _streaming.IsSealedDungeon( @@ -709,19 +944,24 @@ internal sealed class LocalPlayerTeleportController isSealedDungeon); if (!IsCurrentLifetime(generation, sequence)) return false; - worldPosition = new Vector3( - position.Frame.Origin.X, - position.Frame.Origin.Y, - position.Frame.Origin.Z); - } - else - { - worldPosition = translated; } + // C4 route 3: the App-frame-translated `translated`/`worldPosition` + // vector is no longer carried past this point — the canonical + // portal arm resolves the placement through Runtime's OWN world + // frame (resolveWorldOffsetFromRuntimeFrame: true), using the + // cell-local `destination` Position captured HERE rather than an + // App-translated snapshot (trap T4). It must be captured here and + // NOT re-read from the transit at the Place edge: + // _worldReveal.TryBeginPortal (above) drives + // RuntimeWorldTransitState.TryBeginPortalReveal, which atomically + // CONSUMES the transit's one accepted-destination slot the instant + // it claims this reveal generation — a later + // TryGetAcceptedTeleportDestination call always finds it empty. _pendingRotation = position.Frame.Orientation; - _pendingPosition = worldPosition; _pendingCell = position.ObjCellId; + _pendingDestination = destination; + _hasPendingDestination = true; _holdSeconds = 0f; PhysicsDiagnostics.LogTeleport( "AIM", @@ -739,10 +979,13 @@ internal sealed class LocalPlayerTeleportController { long generation = checked(++_lifetimeGeneration); - _pendingPosition = default; _pendingCell = 0u; _pendingRotation = Quaternion.Identity; _pendingRevealGeneration = 0; + _pendingDestination = default; + _hasPendingDestination = false; + _placementCommitted = false; + _awaitingDeferredWake = false; _holdSeconds = 0f; _streaming.ResetRecenter(clearSession); diff --git a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs index 7e7ebeaf..b0320d73 100644 --- a/src/AcDream.App/World/RuntimePlacementPresentationSink.cs +++ b/src/AcDream.App/World/RuntimePlacementPresentationSink.cs @@ -102,7 +102,22 @@ internal sealed class RuntimePlacementPresentationSink projection.Token.Portal, projection.Token.ExactCellId)) { - return false; + // B2 review fix (2026-08-05): acknowledge-and-ignore, same shape + // as Discard/ExecutorCompleted/WithdrawalRestored above. A Place + // whose portal authority went stale (the transit ended or was + // superseded WHILE a DeferredCell park sat outstanding — the + // residual A1's readiness-hold does not close, since it only + // protects the ORDINARY in-flight case) must not be left + // refused at the FIFO head: RuntimePlacementProjectionSubscription + // .OnPlacement never calls Acknowledge on a `false` return, so a + // refused receipt wedges EVERY later entity's placement receipt + // behind it forever. This runs SYNCHRONOUSLY at publish + // (RuntimeAcceptedPositionDriveController's A2 re-validation, by + // contrast, only runs downstream of a receipt this gate ALREADY + // let through — it cannot protect this path). The canonical + // body already committed via RetryDeferred; there is simply no + // live presentation authority left to apply it to. + return true; } if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection)) diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 7e728ffa..f8fcd904 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1171,6 +1171,63 @@ public static class PhysicsDiagnostics $"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}")); } + /// + /// C4 route 3 D-T8 (2026-08-04 — TEMPORARY, strip with the rest of the + /// physics-probe family once the connected gate is scored). One line per + /// local-player portal-arrival attempt from + /// RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal + /// — the single Runtime chokepoint both the graphical and headless hosts + /// share, so this is dual-host parity evidence, not per-host guesswork. + /// Initial state from ACDREAM_PROBE_LOCAL_TELEPORT=1. + /// + public static bool ProbeLocalTeleportEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOCAL_TELEPORT") == "1"; + + /// + /// Which host process is running — set once at composition startup by + /// each host's own entry point (SessionPlayerComposition for + /// graphical, HeadlessSessionHost for headless). Runtime itself + /// stays presentation-agnostic (Slice K); this is a diagnostics-only + /// label so can report which + /// process produced a given line without threading a host parameter + /// through the drive controller's constructor. + /// + public static string LocalTeleportHostKind { get; set; } = "graphical"; + + /// + /// One [local-tp] line: cause, host, placement status, portal + /// generation/sequence, destination cell, resolved cell, and the three + /// D-T8 booleans confirming the reconcile suffix actually ran + /// ( = CommitCanonicalTeleportFrame + /// executed, = the constraint leash is + /// armed post-commit, = + /// CancelAutoRun ran). Self-guards on + /// . is + /// always "portal" today — ACE's recall/admin teleports arrive as + /// the identical TeleportAdvanced Position and are indistinguishable + /// from a doorway portal at this layer; the parameter exists so a future + /// wire-level cause signal has somewhere to land without a probe + /// signature change. + /// + public static void LogLocalTeleportArrival( + string cause, + string placementStatus, + long portalGeneration, + ushort teleportSequence, + uint destinationCell, + uint resolvedCell, + bool hookTailRan, + bool leashArmed, + bool autorunCancelled) + { + if (!ProbeLocalTeleportEnabled) return; + string hookTailText = hookTailRan ? "ran" : "skipped"; + string leashText = leashArmed ? "armed" : "unarmed"; + string autorunText = autorunCancelled ? "cancelled" : "unchanged"; + Console.WriteLine(System.FormattableString.Invariant( + $"[local-tp] cause={cause} host={LocalTeleportHostKind} status={placementStatus} gen={portalGeneration} seq={teleportSequence} dest=0x{destinationCell:X8} resolved=0x{resolvedCell:X8} hookTail={hookTailText} leash={leashText} autorun={autorunText}")); + } + /// /// A6.P3 issue #98 step-walk investigation (2026-05-23). When true, /// emits one [step-walk] line at selected points in the transition diff --git a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs index b4f4777c..b0c29a5b 100644 --- a/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs +++ b/src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs @@ -97,16 +97,26 @@ internal sealed class HeadlessRuntimePlacementProjectionSink if (projection.Kind is not RuntimePlacementProjectionKind.Place) return false; - return record.PositionAuthorityVersion - == token.PositionAuthorityVersion - && record.SpatialAuthorityVersion - == token.SpatialAuthorityVersion - && record.PlacementCommitVersion - == token.PlacementCommitVersion - && record.FullCellId == token.ExactCellId - && _runtime.TransitOwner.IsCurrentPlacementAuthority( + if (record.PositionAuthorityVersion != token.PositionAuthorityVersion + || record.SpatialAuthorityVersion != token.SpatialAuthorityVersion + || record.PlacementCommitVersion != token.PlacementCommitVersion + || record.FullCellId != token.ExactCellId) + { + return false; + } + + if (!_runtime.TransitOwner.IsCurrentPlacementAuthority( token.Portal, - token.ExactCellId); + token.ExactCellId)) + { + // B2 review fix (2026-08-05): acknowledge-and-ignore, the same + // shape and reasoning as the graphical sink's identical fix + // (RuntimePlacementPresentationSink.TryApply) — a stale portal + // authority must not wedge the ordered FIFO for every entity. + return true; + } + + return true; } private static bool HasValidPortalShape( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 19aded57..91fc2743 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -3,6 +3,7 @@ using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; using AcDream.Headless.Policies; using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; @@ -143,6 +144,16 @@ internal sealed class HeadlessSessionHost : IDisposable private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; private AcDream.Core.Net.WorldSession? _currentSession; private HeadlessSessionWorldProjection? _worldProjection; + /// + /// A1/A3 review fix (2026-08-05): retained so can + /// pump + /// alongside 's own + /// PumpFirstEntry — a parked portal placement must retry on the + /// host's own per-tick cadence rather than the completion sequence + /// running unconditionally the instant it is first attempted. Reassigned + /// on every reconnect exactly like . + /// + private RuntimeLiveEntitySessionController? _entities; /// C4 route 4b-1 (N3): the exact route /// last constructed, so can republish the canonical /// placement FIFO every tick — mirrors the graphical host's per-frame @@ -332,6 +343,12 @@ internal sealed class HeadlessSessionHost : IDisposable // collision-generation progress and freshly accepted Creates both // surface here, mirroring the graphical per-frame retry phase. _worldProjection?.PumpFirstEntry(); + // A1/A3 review fix (2026-08-05): retry a parked portal completion + // (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on + // the SAME per-tick cadence, after first-entry so a DeferredCell + // wake first-entry's own pump just resolved is picked up the same + // tick. + _entities?.PumpPortalCompletion(); // C4 route 4b-1 (N3): republish the canonical placement FIFO LAST, // same order as the graphical host's retry-lease callback (drives // first, retry last) — a declined Place left at the FIFO head by @@ -621,6 +638,13 @@ internal sealed class HeadlessSessionHost : IDisposable Radius: 0.48f, Height: 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + // D-T8 (temporary probe): labels every subsequent + // PhysicsDiagnostics.LogLocalTeleportArrival line from THIS + // process as headless — Runtime itself has no host-kind concept + // (Slice K keeps it presentation-agnostic), so this is a + // diagnostics-only label set once at composition time, not a + // Runtime dependency. + PhysicsDiagnostics.LocalTeleportHostKind = "headless"; // C4 route 2: one drive controller per host, mirroring // _firstEntryDrive exactly — same persistent Runtime lifetime, // collision source, and clock. @@ -633,7 +657,20 @@ internal sealed class HeadlessSessionHost : IDisposable () => Runtime.PlayerIdentity.ServerGuid, () => Runtime.MovementOwner.Controller, () => Runtime.CharacterOwner.UsePositionFromServer, - () => _currentSession); + () => _currentSession, + // C4 route 3: the portal arm's PlayerTeleported port needs + // the J5.4 autorun latch owner, one level above the raw + // controller. + () => Runtime.MovementOwner, + // A2/D-T2.4 review fix (2026-08-05): same wiring as the + // graphical composition (SessionPlayerComposition.cs) — the + // SAME idempotent query TryCompletePortal/PrepareDestination + // themselves use. + isPortalAuthorityCurrent: portal => Runtime.TransitOwner + .CanPlacePortalDestination( + portal.RevealGeneration, + portal.TeleportSequence, + portal.Projection.DestinationCell)); var projection = new HeadlessSessionWorldProjection( Runtime, content, @@ -651,6 +688,7 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.Generation.Value), worldProjection, _acceptedPositionDrive); + _entities = entities; var route = new LiveSessionEventRouter( session, entities.CreateSink(), diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index e2991694..a2bd6c15 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -8,6 +8,7 @@ using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; +using AcDream.Runtime.World; namespace AcDream.Headless.Hosting; @@ -585,9 +586,6 @@ internal sealed class HeadlessCollisionNeighborhood internal sealed class HeadlessSessionWorldProjection : IRuntimeDirectWorldProjection { - private const float DefaultRadius = 0.48f; - private const float DefaultHeight = 1.835f; - private readonly GameRuntime _runtime; private readonly IHeadlessCollisionNeighborhood _collision; private readonly RuntimeFirstEntryDriveController? _firstEntry; @@ -749,90 +747,228 @@ internal sealed class HeadlessSessionWorldProjection controller.State = PlayerState.PortalSpace; } + /// + /// A1/A3 review fix (2026-08-05): true while a DeferredCell park + /// from a PRIOR call to this method is outstanding for the local + /// player's one possible pending drive operation. Mirrors + /// LocalPlayerTeleportController._awaitingDeferredWake on the + /// graphical side — avoids re-attempting + /// TryExecuteAcceptedPortalArrival while parked (Runtime's own + /// Begin would just refuse a second overlapping attempt with + /// Contention) by polling + /// + /// instead. + /// + private bool _awaitingPortalWake; + + /// + /// B3 review fix (2026-08-05): keys to + /// the exact reveal it was armed for. The graphical twin + /// (_awaitingDeferredWake) is naturally reset per teleport via + /// ResetTransit; this class is constructed per SESSION, not per + /// teleport, so without this a stale latch from reveal N could silently + /// steal reveal N+1's PrepareDestination call into polling a park + /// that belongs to a different, already-abandoned reveal — skipping the + /// new reveal's placement attempt entirely. + /// + private long _awaitingPortalWakeGeneration; + private ushort _awaitingPortalWakeSequence; + + /// + /// N3 review fix (2026-08-05): bounds how many consecutive + /// NotApplicable attempts this host tolerates before treating the + /// condition as unrecoverable. NotApplicable covers hydration-race + /// transients (no canonical body yet, an active initial-Create residence + /// still owning the record) as well as a genuinely stale reveal — unlike + /// Rejected, it is not established to be permanent, and this host + /// must survive K4's 30-session / two-hour endurance profile without a + /// transient becoming fatal. + /// + private int _notApplicableRetryCount; + private const int NotApplicableRetryBudget = 50; + + /// + /// C4 route 3 (D-T6): the portal-arrival placement runs through the + /// SAME canonical Runtime portal arm the graphical host drives + /// (), + /// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority + /// this method used to own directly (D2; + /// docs/research/2026-08-04-c4-route-3-contract.md D-T6). + /// + /// + /// A1/A3 review fix (2026-08-05): the first pass discarded the arm's + /// returned status entirely (_ = ...) and always reported + /// success, so RuntimeLiveEntitySessionController.TryCompletePortal + /// acknowledged a materialization that never happened on ANY refusal + /// (architecture review A3). This method now: + /// + /// + /// throws if no drive controller was wired — a + /// composition regression must not silently disable placement, never + /// pretend success (A3's second finding); + /// does not even ATTEMPT the placement until + /// reports the destination resident — this + /// host's narrow collision window makes a premature attempt a + /// guaranteed DeferredCell, and 's own + /// readiness IS the signal 's + /// doc names as the precondition for a park to ever resolve; + /// reports IsCollisionReady: false — never + /// success — for every non-Committed outcome, so the caller's + /// retry loop (RuntimeLiveEntitySessionController.PumpPortalCompletion, + /// A1's headless-side fix) keeps calling this method instead of the + /// completion sequence running against an unplaced body; a genuine + /// DeferredCell is therefore never an error, only a wait — and + /// throws only for the two statuses that mean something is actually + /// wrong (Rejected/NotApplicable — the reveal itself is + /// stale, or the local player has no canonical body, neither of which + /// a headless bot can recover from by waiting). + /// + /// public RuntimeDestinationReadiness PrepareDestination( long revealGeneration, - RuntimeTeleportDestination destination) + RuntimeTeleportDestination destination, + RuntimeWorldHostProjectionToken portal) { _collision.CenterOn(destination.CellId); - if (_runtime.EntityObjects.Entities.TryGetActive( - destination.EntityGuid, - out RuntimeEntityRecord record)) + if (_acceptedPositionDrive is null) { - ResynchronizeLocalPlayerForPortalArrival(record); + throw new InvalidOperationException( + "Headless portal placement requires a wired " + + "RuntimeAcceptedPositionDriveController - a composition " + + "regression must not silently disable placement (A3)."); } - if (_runtime.MovementOwner.Controller is { } controller) + + // B3: a latch armed for a DIFFERENT reveal must not be consulted + // for this one — fall through to a fresh attempt below instead. + if (_awaitingPortalWake + && (_awaitingPortalWakeGeneration != revealGeneration + || _awaitingPortalWakeSequence != destination.TeleportSequence)) + { + _awaitingPortalWake = false; + } + + bool committed; + if (_awaitingPortalWake) + { + if (_acceptedPositionDrive.PendingCount != 0) + { + committed = false; + } + else + { + // B1 review fix (2026-08-05): "not pending" is not + // "committed" — the drive's own doc names a merge-time + // Forget (an ordinary ACE broadcast arriving mid-park) as + // the EXPECTED way a park resolves without committing. + // TryConsumePortalCommit is the drive's OWN record of + // whether ITS commit actually happened for this exact + // reveal/sequence, latched only inside a real + // ReconcileAndAcknowledgePortal call — never inferred. + _awaitingPortalWake = false; + committed = _acceptedPositionDrive.TryConsumePortalCommit( + revealGeneration, destination.TeleportSequence); + // A "no" here falls through to committed=false below; the + // NEXT PrepareDestination call re-attempts fresh since + // _awaitingPortalWake is now false and nothing is pending. + } + } + else if (!_collision.IsReady(destination.CellId)) + { + committed = false; + } + else + { + var authority = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: revealGeneration, + TeleportSequence: destination.TeleportSequence, + Projection: portal); + RuntimeAcceptedPositionExecutionStatus status = + _acceptedPositionDrive.TryExecuteAcceptedPortalArrival( + destination, + authority); + switch (status) + { + case RuntimeAcceptedPositionExecutionStatus.Committed: + committed = true; + _notApplicableRetryCount = 0; + break; + case RuntimeAcceptedPositionExecutionStatus.DeferredCell: + _awaitingPortalWake = true; + _awaitingPortalWakeGeneration = revealGeneration; + _awaitingPortalWakeSequence = destination.TeleportSequence; + committed = false; + break; + case RuntimeAcceptedPositionExecutionStatus.Contention: + // Transient - some other operation still owns the + // entity's placement token. Retried next pump; never a + // hard error, matching the graphical arm's D-T5 + // refusal shape. + committed = false; + break; + case RuntimeAcceptedPositionExecutionStatus.NotApplicable: + // N3 review fix (2026-08-05): NotApplicable covers + // hydration-race transients (record.PhysicsBody is + // null, or an active initial-Create residence still + // owns the record — RuntimeAcceptedPositionDriveController's + // own guard) as well as a genuinely stale reveal; unlike + // Rejected it is not established to be permanent. + // Bounded, loud retry rather than an immediate throw — + // this host must survive K4's 30-session/two-hour + // endurance profile without a transient becoming fatal. + _notApplicableRetryCount++; + PhysicsDiagnostics.LogTeleport( + "REFUSED", + destination.CellId, + $"cause=NotApplicable attempt={_notApplicableRetryCount}"); + if (_notApplicableRetryCount > NotApplicableRetryBudget) + { + throw new InvalidOperationException( + "Headless portal placement stayed NotApplicable " + + $"for {_notApplicableRetryCount} consecutive " + + "attempts (no canonical body, or an active " + + "initial-Create residence still owns the " + + "record) - exceeded the bounded retry budget."); + } + committed = false; + break; + default: + throw new InvalidOperationException( + $"Headless portal placement refused with " + + $"status={status} even though the destination's " + + "collision neighborhood reported ready - the " + + "reveal itself is stale, not recoverable by " + + "waiting (contract §4 item 5 forbids " + + "acknowledging a materialization that did not " + + "happen)."); + } + } + + if (committed && _runtime.MovementOwner.Controller is { } controller) controller.State = PlayerState.InWorld; - bool ready = _collision.IsReady(destination.CellId); bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u; return new RuntimeDestinationReadiness( revealGeneration, destination.CellId, indoor, - IsUnhydratable: !ready, + // N2 review fix (2026-08-05): hardcoded false, NOT DERIVED. + // AD-2's "loud unhydratable-placement path" (a claim beyond + // NumCells) is a graphical-only concept today — + // WorldRevealReadinessBarrier's render/composite-texture domains + // have no headless analogue, so there is no local predicate this + // no-window host could evaluate. A genuinely unhydratable + // destination therefore reports IsCollisionReady=false forever + // (via the bounded DeferredCell retry above) rather than taking + // AD-2's loud path — headless does not model unhydratable + // claims. If headless ever gains its own resident-cell-set + // concept, derive the real predicate here instead of leaving + // this hardcoded. + IsUnhydratable: false, RequiredRenderRadius: indoor ? 0 : 1, IsRenderNeighborhoodReady: true, AreCompositeTexturesReady: true, - IsCollisionReady: ready); - } - - /// - /// TODO-C4 (route 3): portal-arrival re-synchronization only. The - /// route-1/8 initial-entry hand-copy (controller construction + first - /// resolve/placement) was deleted at C3c — the first-entry conductor's - /// publication chain owns it — but the portal route is unflipped, so its - /// arrival re-resolve keeps today's exact behavior against the - /// already-published controller until C4 routes it through - /// RuntimePortalPlacementAuthority. - /// - private void ResynchronizeLocalPlayerForPortalArrival( - RuntimeEntityRecord record) - { - if (record.ServerGuid - != _runtime.PlayerIdentity.ServerGuid - || record.Snapshot.Position is not { } position - || _runtime.MovementOwner.Controller is not { } controller) - { - return; - } - - _collision.CenterOn(position.LandblockId); - Vector3 wirePosition = new( - position.PositionX, - position.PositionY, - position.PositionZ); - Quaternion orientation = new( - position.RotationX, - position.RotationY, - position.RotationZ, - position.RotationW); - - ResolveResult resolved = - _runtime.EntityObjects.Physics.Engine.Resolve( - wirePosition, - position.LandblockId, - Vector3.Zero, - 100f); - ResolveResult placement = - _runtime.EntityObjects.Physics.Engine.ResolvePlacement( - resolved.Position, - resolved.CellId, - DefaultRadius, - DefaultHeight, - controller.StepUpHeight, - controller.StepDownHeight, - ObjectInfoState.IsPlayer - | ObjectInfoState.EdgeSlide, - record.LocalEntityId ?? 0u); - if (placement.Ok) - resolved = placement; - - controller.LocalEntityId = record.LocalEntityId ?? 0u; - controller.SetPosition( - resolved.Position, - resolved.CellId, - wirePosition); - controller.SetBodyOrientation(orientation); + IsCollisionReady: committed); } } diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index c49ae0e1..57a164f5 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -1949,6 +1949,145 @@ public sealed class PlayerMovementController UpdateCellId(_body.CellPosition.ObjCellId, "force-position"); } + /// + /// C4 route 3: the controller-local half of a portal-teleport commit + /// whose body write, cell install, and orientation already happened + /// inside Runtime's canonical RuntimeSetPositionState.CommitCanonical + /// (retail CPhysicsObj::SetPositionSimple @0x005162B0 with flags + /// 0x1012, called from SmartBox::TeleportPlayer @0x00453910, + /// acclient_2013_pseudo_c.txt:284276/92528). Unlike + /// (which the FORCE_POSITION + /// branch's early return at @0x0045409D exempts from every + /// ConstrainTo), the local TELEPORT branch of + /// SmartBox::HandleReceivedPosition (@0x0045415F) DOES re-arm the + /// leash (@0x0045418A, anchored at the received destination) and DOES + /// zero velocity (@0x004541B4) — the inversion is deliberate, not a + /// missed exemption; see docs/research/2026-08-04-c4-route-3-contract.md + /// §2 Inversion A. + /// + /// Performs every duty NOT already covered + /// by the canonical commit (P1's duty map): render-lerp anchor reset, + /// UpdateCellId publication, the retail teleport_hook tail + /// (UnStick @0x00514eee / UnConstrain @0x00514f02 / re-arm @0x0045418A), + /// the retail StopCompletely full stop (0x00527e40, zeroes velocity and + /// resets fwd/sidestep/turn commands so input resumes at rest), the + /// input-edge/mouse press-edge reset, and the physics-clock reset for a + /// fresh update_object boundary. TransientState (Contact/OnWalkable/ + /// Sliding/WaterContact) is deliberately NOT re-seeded here — the canonical + /// commit's PhysicsObjUpdate.CommitSetPositionContactTransition + /// already derives those bits from the SLIDE placement's OWN resolved + /// contact result, which is more retail-faithful than the old + /// 's unconditional + /// Contact|OnWalkable|Active overwrite (that overwrite could mark a + /// portal arrival grounded even when the destination placement actually + /// resolved airborne). Active is untouched because a live in-world + /// local player already carries it; the canonical commit only sets it on + /// entry from a celless residence, which a portal arrival never is. + /// + /// + /// A4 review fix (2026-08-05): the two inversions this method embodies + /// are named, retail-cited facts on the classifier's + /// RuntimeAuthoritativePositionRouteZeroVelocity and + /// ConstrainPhase.AfterPositionOperation — and this method must + /// actually READ them rather than assume the LocalPlayer-teleport + /// branch's values are the only ones that will ever reach it. Both + /// parameters are the route's own facts, passed by the one caller + /// (RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal); + /// a future classifier edit that changes either value now changes this + /// method's behaviour instead of silently disagreeing with it. + /// + /// Coordinator note (round-3 closeout, 2026-08-05): the two parameters + /// are read, not hardcoded — but they are NOT equally load-bearing. + /// is read and applied, then + /// runs + /// UNCONDITIONALLY on the very next line and zeroes velocity again — so + /// a zeroVelocity: false sabotage changes nothing observable + /// here; the field is proven read but not proven DISCRIMINATING. + /// (ConstrainAfterRouting) + /// has no such unconditional fallback and IS the load-bearing one — + /// it alone decides whether the leash re-arms. Do not read this doc + /// comment as proving both fields equally; only the leash flag is. + /// + /// + /// + /// N4 review fix (2026-08-05): before this parameter, the caller gated + /// the ENTIRE method call on route.RunsTeleportHook — but retail's + /// SetPositionInternal @0x00515330 does the frame/cell/stop/input- + /// reset/clock work UNCONDITIONALLY; only retail's teleport_hook + /// @0x00514ED0 (UnStick/UnConstrain/re-arm, mapped below) is itself + /// conditional on the hook phase. Gating the whole call meant a future + /// + /// of None would silently skip the render-root UpdateCellId + /// publish too — the doorway-FLAP class. Today the portal route always + /// sets a non-None phase, so this parameter is always true in + /// production and there is no live behavior change; it exists so a + /// future None phase changes only the hook tail, not the frame + /// commit. + /// + internal void CommitCanonicalTeleportFrame( + bool zeroVelocity, + bool rearmConstraintLeash, + bool runTeleportHookTail = true) + { + EnsurePublishedForRuntimeOperation(); + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; + UpdateCellId(_body.CellPosition.ObjCellId, "teleport"); + + // Retail set_velocity(player, 0, 1) @0x004541B4 — route.ZeroVelocity. + if (zeroVelocity) + _body.Velocity = Vector3.Zero; + // Retail teleport idle is a FULL stop (StopCompletely 0x00527e40): + // resets fwd/sidestep/turn COMMANDS and zeroes velocity again so the + // motion interpreter cannot reconstruct the pre-teleport run vector + // the instant input resumes. + StopCompletelyAtPhysicsObjectBoundary(); + _activeInputTurnCommand = null; + _activeInputTurnSpeed = 0f; + _activeInputTurnFromMouse = false; + _activeInputSidestepCommand = null; + _activeInputSidestepUsesRunHold = false; + _mouseLookActive = false; + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + _mouseMovementEventCandidate = false; + _mouseMovementEventPending = false; + + // Retail teleport_hook @0x00514ed0 tears down any active stick/leash + // unconditionally, then HandleReceivedPosition's TELEPORT branch + // immediately re-arms the leash anchored to the just-committed + // position ONLY when ConstrainPhase is AfterPositionOperation + // (Inversion A — the opposite of + // CommitCanonicalForcePositionFrame's no-re-arm rule, itself + // route.ConstrainPhase.None for FORCE_POSITION). N4 review fix: this + // is the ONLY part of this method retail actually conditions on the + // teleport-hook phase — everything above runs unconditionally. + if (runTeleportHookTail) + { + PositionManager?.UnStick(); + PositionManager?.UnConstrain(); + if (rearmConstraintLeash) + RearmConstraintLeashAtCurrentPosition(); + } + + // 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 SetPositionCore's walking-straight-out-of-a- + // teleport behavior while W stays held). + _prevForwardHeld = false; + _prevBackwardHeld = false; + _prevStrafeLeftHeld = false; + _prevStrafeRightHeld = false; + _prevTurnLeftHeld = false; + _prevTurnRightHeld = false; + _prevRunHeld = false; + _hasInputSnapshot = false; + + // Reset physics clock so any subsequent update_object calls start fresh. + _body.LastUpdateTime = 0.0; + _objectClock.ResetForEnterWorld(); + } + private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp( diff --git a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs index b6ef2c68..d5454be3 100644 --- a/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs @@ -130,6 +130,11 @@ internal enum RuntimeAcceptedPositionExecutionStatus : byte /// early return (@0x0045409D) never reaches; the deleted /// PlayerMovementController.BlipPosition's re-arm was an unbacked /// deviation this route retires (docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b). +/// This no-re-arm rule is scoped to FORCE_POSITION only — C4 route 3's +/// portal arm () DOES re-arm, +/// because retail's local TELEPORT branch of the same function reaches +/// ConstrainTo @0x0045418A; see +/// docs/research/2026-08-04-c4-route-3-contract.md §2 Inversion A. /// /// One instance per host session route (graphical/headless), constructed /// once per host process and reused across reconnects exactly like @@ -170,8 +175,26 @@ public sealed class RuntimeAcceptedPositionDriveController /// begin: that marker stands for a packet whose placement was never /// begun, so its ack is owed by the eventual re-issue's own terminal /// outcome, not by the marker. + /// + /// C4 route 3: always false for a portal pending — the + /// portal route's + /// is always false (retail's teleport branch never sends + /// AutonomousPosition), so there is never an owed position + /// event to carry. /// internal required bool PositionEventOwed { get; init; } + + /// + /// C4 route 3: + /// when this descriptor is the trap T7 (2026-08-04 contract) portal + /// arm's own DeferredCell park — a SIBLING use of this same + /// retained-operation machinery, not a repurposed force pending. + /// and are + /// the only writers/readers that branch on it; the force funnel + /// (, ) never + /// sees or produces a portal pending. + /// + internal RuntimePortalPlacementAuthority Portal { get; init; } } /// @@ -218,6 +241,40 @@ public sealed class RuntimeAcceptedPositionDriveController private readonly Func _usePositionFromServer; private readonly Func _session; + /// + /// C4 route 3: the D-T3 PlayerTeleported port + /// (CommandInterpreter::PlayerTeleported @0x006B32B0 = + /// SetAutoRun(0,1) + SendMovementEvent) needs the J5.4 + /// autorun latch owner, which lives one level above + /// and is not reachable from + /// . Late-bound like every other + /// dependency here so this controller does not need to outlive a + /// specific movement-owner instance across a reconnect. + /// + private readonly Func _localMovementState; + + /// + /// A2 review fix (2026-08-05, D-T2.4): re-validates a retained portal + /// authority against the transit owner's CURRENT reveal before either + /// wake site in acts on it. A DeferredCell + /// park commits asynchronously (RuntimeSetPositionState.RetryDeferred, + /// driven entirely by an unrelated collision-generation wake) — nothing + /// in this class can prevent that body-level commit once it starts. What + /// this predicate CAN prevent is running the reconcile/ack suffix (or a + /// stale resubmission) against a reveal that ended or was superseded + /// while the park was outstanding — exactly the D-T2.4 requirement this + /// slice's first pass never implemented (architecture review A2). Wired + /// by each host composition to + /// RuntimeWorldTransitState.CanPlacePortalDestination (the SAME + /// idempotent query 's + /// caller already uses at the Place edge); left + /// by fixtures that do not exercise the DeferredCell wake, in which case + /// every retained portal pending is treated as still current (today's + /// unconditional behaviour, preserved for callers that never park). + /// + private readonly Func? + _isPortalAuthorityCurrent; + /// /// The drive's at-most-one in-flight placement for the local player. /// Round 2 unified mechanism (2026-08-03): exactly THREE members write @@ -244,7 +301,9 @@ public sealed class RuntimeAcceptedPositionDriveController Func localPlayerServerGuid, Func localController, Func usePositionFromServer, - Func session) + Func session, + Func? localMovementState = null, + Func? isPortalAuthorityCurrent = null) { _entityObjects = entityObjects ?? throw new ArgumentNullException(nameof(entityObjects)); @@ -262,12 +321,53 @@ public sealed class RuntimeAcceptedPositionDriveController _usePositionFromServer = usePositionFromServer ?? throw new ArgumentNullException(nameof(usePositionFromServer)); _session = session ?? throw new ArgumentNullException(nameof(session)); + _localMovementState = localMovementState ?? (static () => null); + _isPortalAuthorityCurrent = isPortalAuthorityCurrent; _entityObjects.RegisterAcceptedPositionDriveOwnership( () => _pending is null ? 0 : 1); } internal int PendingCount => _pending is null ? 0 : 1; + /// + /// B1 review fix (2026-08-05): the drive's own record of the LAST portal + /// authority actually + /// committed — set only there, so this is never an inference. Both host + /// gates were latching "committed" from + /// reaching zero, but that global (force-arm-shared) slot ALSO clears on + /// three non-committing paths (a merge-time Forget — the drive's + /// own doc names this the EXPECTED outcome of a park surviving one ACE + /// broadcast interval — and both of A2's new abandon branches), so + /// "not pending" never implied "this specific reveal placed". Consumed + /// exactly once per commit via . + /// + private (long RevealGeneration, ushort TeleportSequence)? _lastCommittedPortal; + + /// + /// B1 review fix: the host gate's ONLY correct way to learn "did MY + /// specific reveal commit" — never infer it from + /// . Returns and + /// consumes the fact exactly once when the drive's last portal commit + /// matches the caller's own (revealGeneration, teleportSequence); a + /// mismatch (nothing committed yet, a DIFFERENT reveal committed, or + /// this generation's park was abandoned/forgotten instead) returns + /// without side effects, so the caller keeps + /// retrying or falls through to a fresh attempt. + /// + internal bool TryConsumePortalCommit( + long revealGeneration, + ushort teleportSequence) + { + if (_lastCommittedPortal is not { } committed + || committed.RevealGeneration != revealGeneration + || committed.TeleportSequence != teleportSequence) + { + return false; + } + _lastCommittedPortal = null; + return true; + } + /// /// C3c-R1-style one-route-at-a-time latch (mirrors /// ): this @@ -308,10 +408,22 @@ public sealed class RuntimeAcceptedPositionDriveController /// force observation dies with it: a reconnect re-merges its own /// positions, and a stale observation must never survive to authorize a /// re-issue against a later session's record. + /// + /// Coordinator hygiene fix (round-3 closeout, 2026-08-05): also clears + /// . An unconsumed latch surviving a + /// session reset was harmless only because the transit's own + /// generation counter is monotonic across resets within one + /// GameRuntime lifetime, so a stale entry could never match a + /// later reveal's generation/sequence pair by construction — a + /// correctness argument resting on an invariant this method never + /// declared. Clearing it here lets the ledger converge to zero on + /// every reset instead of relying on that invariant to stay true. + /// /// private void AbandonPending() { _newestForce = null; + _lastCommittedPortal = null; if (_pending is not { } pending) return; _pending = null; @@ -401,6 +513,411 @@ public sealed class RuntimeAcceptedPositionDriveController return SubmitAndResolve(record, token, route); } + /// + /// C4 route 3: executes the local player's portal arrival against the + /// canonical Runtime SetPosition owner. Retail + /// SmartBox::TeleportPlayer @0x00453910 = + /// CPhysicsObj::SetPositionSimple(player, dest, 1) — the SAME + /// generic primitive route 2 already routes through + /// — plus + /// PlayerPositionUpdated. must be + /// the transit's OWN retained accepted destination + /// (RuntimeWorldTransitState.TryGetAcceptedTeleportDestination), + /// never re-derived from live per-tick timestamps: by the time the Place + /// edge fires, the packet merged seconds ago and nothing is "advancing" + /// anymore (docs/research/2026-08-04-c4-route-3-contract.md D-T2.2). + /// + internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedPortalArrival( + in RuntimeTeleportDestination destination, + in RuntimePortalPlacementAuthority portal) + { + if (!portal.IsValid + || !_entityObjects.Entities.TryGetActive( + _localPlayerServerGuid(), out RuntimeEntityRecord record) + || record.PhysicsBody is null + || record.Key is not { } key + // Route 1 owns an active initial-Create residence exactly like + // route 2's equivalent guard above — the residence executor's + // own tail action already carries any position it needs. + || _entityObjects.TryGetInitialCreateResidence(record, out _)) + { + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.NotApplicable, + portal, + resolvedCell: 0u); + return RuntimeAcceptedPositionExecutionStatus.NotApplicable; + } + + RuntimeAuthoritativePositionRoute route = ClassifyPortalArrival( + record, key, destination, _generation()); + if (!route.Accepted) + { + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Rejected, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + ulong acceptedVersion = record.PositionAuthorityVersion; + RuntimeEntityPlacementToken token = + setPosition.TryBeginExclusiveAuthoredPlacement( + record, + acceptedVersion, + route.OperationKind, + portal); + if (!token.IsValid) + { + // Either a concurrent placement authority already owns the + // entity, or Begin's own portal-vs-latest-cell gate refused + // (D-T5's Begin cell-mismatch edge — a second local Position + // merged between the offer and this Place edge). Neither is + // staleness; the caller's D-T5 refusal handling owns what + // happens next. + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Contention, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Contention; + } + + return SubmitAndResolvePortal(record, token, route, portal); + } + + /// + /// C4 route 3: the classifier's LocalPlayer-teleport route + /// (, + /// request.Authority.TeleportAdvanced branch) built from the + /// retained destination rather than a live merge. Retail's + /// PhysicsTimestampGate.IsNewer(PreviousTeleportSequence, + /// AcceptedTeleportSequence) gate only needs to be TRUE — its exact + /// magnitude is not read anywhere past that boolean (the classifier's + /// resulting for this + /// branch does not depend on the previous stamp's value, and the drive + /// controller's own `expectedPositionAuthorityVersion` — not this + /// authority's — gates Begin), so a synthetic strictly-older sequence + /// forces retail's exact branch without any second copy of the merge-time + /// timestamp pair having to survive from offer to Place. + /// + private static RuntimeAuthoritativePositionRoute ClassifyPortalArrival( + RuntimeEntityRecord record, + RuntimeEntityKey key, + in RuntimeTeleportDestination destination, + RuntimeGenerationToken generation) + { + ushort acceptedTeleport = destination.TeleportSequence; + ushort priorTeleport = unchecked((ushort)(acceptedTeleport - 1)); + var authority = new RuntimeAuthoritativePositionAuthority( + generation, + key, + record.PositionAuthorityVersion, + destination.PositionSequence, + priorTeleport, + acceptedTeleport, + PositionTimestampDisposition.Apply); + + bool hasAnimations = (record.Snapshot.MotionTableId + ?? record.Snapshot.Physics?.MotionTableId) is { } motionTableId + && motionTableId != 0u; + + var wirePosition = new CreateObject.ServerPosition( + destination.CellId, + destination.Position.Frame.Origin.X, + destination.Position.Frame.Origin.Y, + destination.Position.Frame.Origin.Z, + destination.Position.Frame.Orientation.W, + destination.Position.Frame.Orientation.X, + destination.Position.Frame.Orientation.Y, + destination.Position.Frame.Orientation.Z); + + var request = new RuntimeAcceptedPositionRouteRequest( + authority, + RuntimePositionEntityKind.LocalPlayer, + RuntimeAcceptedPositionSource.PositionEvent, + wirePosition, + PlacementFrame: null, + PositionPackVelocity: null, + CommittedCellId: record.FullCellId, + HasContact: false, + PlayerDistance: 0f, + UsePositionFromServer: false, + hasAnimations, + new RuntimePositionPlacementFacts( + record.FinalPhysicsState, + record.Snapshot.SetupTableId is not null)); + + return RuntimeAuthoritativePositionRouteClassifier + .ClassifyAcceptedPosition(request); + } + + /// + /// C4 route 3 (trap T7): the portal SIBLING of + /// — shares Begin/Submit/status handling, deliberately does NOT touch + /// or route through 's + /// force-shaped re-issue funnel. ACE sends one destination per teleport; + /// a portal placement that fails to commit is never re-applied. + /// + private RuntimeAcceptedPositionExecutionStatus SubmitAndResolvePortal( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + in RuntimeAuthoritativePositionRoute route, + in RuntimePortalPlacementAuthority portal) + { + RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition; + RuntimeSetPositionMoverPreparationStatus status = + setPosition.TryPrepareAndSubmitAuthoredPlacement( + record, + token, + route.OperationKind, + route.SetPositionFlags, + _collisionSource, + _clock.SimulationTimeSeconds, + out RuntimeSetPositionOutcome outcome, + portal: portal, + resolveWorldOffsetFromRuntimeFrame: true); + + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (status.IsRetryable()) + { + RetainPending(setPosition, new Pending + { + Record = record, + Token = token, + Route = route, + AwaitingCommitWake = false, + PositionEventOwed = false, + Portal = portal, + }); + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Contention, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Contention; + } + + CancelToken(setPosition, token); + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Rejected, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + + switch (outcome.Status) + { + case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending: + ReconcileAndAcknowledgePortal(record, route, portal); + return RuntimeAcceptedPositionExecutionStatus.Committed; + + case RuntimeSetPositionStatus.DeferredCell: + // D-T2.4: a park should be rare (the destination was already + // centered by the host before submit), but must never leak — + // same drain-stale-Withdraw-then-watch shape as the force arm. + while (setPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot parked) + && parked.Token.Entity == token.Entity + && parked.Kind is RuntimePlacementProjectionKind.Withdraw) + { + if (!setPosition.AcknowledgeProjection(parked.Token)) + break; + } + if (!setPosition.WatchPlacementCompletion(token)) + { + CancelToken(setPosition, token); + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Rejected, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + RetainPending(setPosition, new Pending + { + Record = record, + Token = token, + Route = route, + AwaitingCommitWake = true, + PositionEventOwed = false, + Portal = portal, + }); + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.DeferredCell, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.DeferredCell; + + default: + // Rejected/Cancelled — authority moved out from under this + // operation; the body never moved. Unlike the force arm, + // retail's teleport branch has no unconditional ack to send, + // so there is nothing left to do here. + CancelToken(setPosition, token); + LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus.Rejected, + portal, + record.FullCellId); + return RuntimeAcceptedPositionExecutionStatus.Rejected; + } + } + + /// + /// R8 review fix (2026-08-05): D-T8 specified "one line per + /// portal-arrival ATTEMPT", but the first pass logged only from + /// — reached solely on + /// Committed — so every refusal was invisible under the gate's + /// own pinned ACDREAM_PROBE_LOCAL_TELEPORT env var (the graphical + /// refusal path logged under the DIFFERENT ACDREAM_PROBE_TELEPORT, + /// and headless logged nothing at all). Every non-terminal/refusal exit + /// from and + /// now emits through this one + /// helper; the richer hookTail/leash/autorun facts remain + /// 's own line on the + /// Committed path, since those three booleans are meaningless + /// before a commit. + /// + private static void LogPortalArrivalAttempt( + RuntimeAcceptedPositionExecutionStatus status, + in RuntimePortalPlacementAuthority portal, + uint resolvedCell) + { + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: status.ToString(), + portalGeneration: portal.RevealGeneration, + teleportSequence: portal.TeleportSequence, + destinationCell: portal.Projection.DestinationCell, + resolvedCell: resolvedCell, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + } + + /// + /// A2/D-T2.4 review fix (2026-08-05): treats a portal authority as + /// current when no re-validation predicate was wired (today's + /// unconditional behaviour, preserved for fixtures that never park), and + /// otherwise defers to — which + /// production wires to the SAME + /// RuntimeWorldTransitState.CanPlacePortalDestination query the + /// App/headless Place edge itself uses. + /// + private bool IsPortalAuthorityCurrent( + in RuntimePortalPlacementAuthority portal) => + _isPortalAuthorityCurrent is null || _isPortalAuthorityCurrent(portal); + + /// + /// C4 route 3: the committed-portal-placement controller-local + /// reconciliation and outbound tail. Runs + /// + /// (the re-homed SetPositionCore duties, D-T3), then the + /// PlayerTeleported port (CommandInterpreter::PlayerTeleported + /// @0x006B32B0 = SetAutoRun(0,1) + SendMovementEvent) — two + /// named behaviour changes versus the deleted App/Headless placement + /// authorities: autorun now cancels on portal arrival, and exactly one + /// movement-event refresh goes out (never an AutonomousPosition — + /// the route's SendPositionImmediately is always false). + /// + /// + /// A4 review fix (2026-08-05): 's + /// ZeroVelocity/ConstrainPhase/TeleportHookPhase are + /// now READ, not assumed — + /// gates whether the hook tail runs at all, and its + /// ZeroVelocity/ + /// drive CommitCanonicalTeleportFrame's two conditional duties. + /// The LocalPlayer-teleport branch's values are unchanged today + /// (AfterPositionOperation/AfterPositionOperation/true), + /// so this is purely a wiring correction: a future classifier edit now + /// changes this method's behaviour instead of silently disagreeing with + /// it, and the contract's own §8 item 11 sabotage (force the classifier + /// onto ConstrainPhase.None — the leash must not re-arm) can + /// finally fail as designed. + /// + /// + private void ReconcileAndAcknowledgePortal( + RuntimeEntityRecord record, + in RuntimeAuthoritativePositionRoute route, + in RuntimePortalPlacementAuthority portal) + { + // B1 review fix (2026-08-05): this method is called ONLY from the + // two sites that just observed Runtime's canonical + // CommittedHostAcknowledgementPending outcome for THIS portal + // authority (SubmitAndResolvePortal's first-attempt commit and + // Advance's re-validated deferred wake) — so the commit fact is + // true here regardless of whether the two guards below decline the + // REST of this method's App-level suffix work. Latching it FIRST, + // unconditionally, is what lets TryConsumePortalCommit replace the + // unsound PendingCount==0 inference both host gates used to make. + _lastCommittedPortal = (portal.RevealGeneration, portal.TeleportSequence); + if (record.ServerGuid != _localPlayerServerGuid()) + return; + if (_localController() is not { } controller) + return; + // N4 review fix (2026-08-05): the frame/cell/stop/input-reset/clock + // commit runs UNCONDITIONALLY (retail's SetPositionInternal + // @0x00515330 has no hook-phase gate); only the UnStick/UnConstrain/ + // re-arm tail inside it is conditioned on the hook phase, via + // runTeleportHookTail. Previously this whole call was skipped when + // RunsTeleportHook was false, which would have silently dropped the + // render-root UpdateCellId publish too (the doorway-FLAP class) the + // day a route ever sets TeleportHookPhase.None — today's portal + // route always sets a non-None phase, so this is a structural fix + // with no live behavior change yet. + bool hookTailRan = route.RunsTeleportHook; + controller.CommitCanonicalTeleportFrame( + zeroVelocity: route.ZeroVelocity, + rearmConstraintLeash: route.ConstrainAfterRouting, + runTeleportHookTail: hookTailRan); + bool autorunCancelled = _localMovementState()?.CancelAutoRun() ?? false; + // R7 review fix (2026-08-05): retail CommandInterpreter::SendMovementEvent + // @0x006B4680 (PlayerTeleported's tail-jump) gates on TWO facts — a + // non-null raw motion state (TryGetOutboundPosition/TryGetOutboundMotion + // already cover that) AND `autonomy_level != 0`. This call was + // unconditional. This is route 3's OWN call site only — + // LocalPlayerOutboundController.TrySendMovement is shared with + // route 2's DIFFERENT retail function + // (CommandInterpreter::SendPositionEvent) and is not touched. + // + // Known approximation, filed AP-144 (2026-08-05, R7 round-3 review — + // CLAUDE.md's register rule is binding, not an implementer's call): + // this class only has RuntimeCharacterState.UsePositionFromServer in + // scope (`AutonomyLevel != FullAutonomyLevel(2)`, retail's + // `autonomy_level != 2`), not the raw AutonomyLevel — so + // `!UsePositionFromServer` sends only when AutonomyLevel==2, + // whereas retail's actual gate (`autonomy_level != 0`) ALSO sends + // at AutonomyLevel==1. The two agree everywhere except that one mid + // level, currently unreachable because TrySetAutonomyLevel has zero + // production callers. Retire by threading the raw AutonomyLevel + // through this constructor (and both host compositions) and gating + // on `!= 0` directly instead of reusing UsePositionFromServer. + if (!_usePositionFromServer()) + { + _localPlayerOutbound.TrySendMovement( + _session(), + controller, + controller.CapturePresentationResult()); + } + + // D-T8 probe (temporary): confirms the reconcile suffix actually + // ran its three named duties on THIS commit, not just that the + // commit was reached. R3 review fix: the leash observable is + // ConstraintManager.IsConstrained ("has a leash"), not + // IsFullyConstrained ("has strained past 90% of it") — the latter + // reads false immediately after ConstrainTo re-anchors at distance + // 0, so every committed arrival printed leash=unarmed as coded. + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: "Committed", + portalGeneration: portal.RevealGeneration, + teleportSequence: portal.TeleportSequence, + destinationCell: portal.Projection.DestinationCell, + resolvedCell: record.FullCellId, + hookTailRan: hookTailRan, + leashArmed: controller.PositionManager?.Constraint?.IsConstrained + ?? false, + autorunCancelled: autorunCancelled); + } + /// /// Host cadence pump: resolves a parked DeferredCell operation once its /// destination landblock's collision generation eventually commits it @@ -461,6 +978,42 @@ public sealed class RuntimeAcceptedPositionDriveController // Raced against a concurrent consumer; retry next pump. return; } + // C4 route 3 (trap T7): a portal pending never enters the + // force funnel — SettlePending's _newestForce re-issue + // decision belongs to the force arm only. The deferred + // commit's own reconciliation is the portal wake's entire + // terminal action. + if (pending.Portal.Present) + { + _pending = null; + // A2/D-T2.4 re-validation: RetryDeferred already moved + // the body (asynchronously, outside this class's + // control — nothing here can prevent that). What this + // CAN prevent is running the reconcile/ack suffix + // against a reveal that ended or was superseded while + // the park sat outstanding, which would otherwise + // publish a Place receipt naming a dead portal + // authority (architecture review A2's FIFO-wedge + // shape). + if (!IsPortalAuthorityCurrent(pending.Portal)) + { + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: "AbandonedAtWake", + portalGeneration: pending.Portal.RevealGeneration, + teleportSequence: pending.Portal.TeleportSequence, + destinationCell: + pending.Portal.Projection.DestinationCell, + resolvedCell: pending.Record.FullCellId, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return; + } + ReconcileAndAcknowledgePortal( + pending.Record, pending.Route, pending.Portal); + return; + } // Retail order: the deferred commit's own reconciliation and // ack come first, THEN the funnel decides whether a newer // accepted force is still owed a placement (B1). @@ -480,9 +1033,39 @@ public sealed class RuntimeAcceptedPositionDriveController } // The watch died — most likely a subsequent accepted Position's - // merge-time Forget. The funnel owns what happens next, including - // this packet's still-unsent position event (retail acks whether - // or not the placement took — see SettlePending). + // merge-time Forget. A portal pending owes no re-issue and no + // ack (SendPositionImmediately is always false for the portal + // route), so it simply clears — matching the D-T5 refusal shape + // (nothing mutates; the transit's own cancellation/supersession + // machinery is the authority on what happens next). + // + // B1/N1 review fix (2026-08-05): this is the drive's own + // documented MODAL park outcome (ACE's 5-10 Hz broadcast Forgets + // any park surviving one interval — "the exact far-destination + // case the park exists to serve"), not a corner case. It does + // NOT set _lastCommittedPortal — nothing committed — so the host + // gate's TryConsumePortalCommit correctly reports "not yet" and + // either re-attempts fresh or converges through the transit's + // own cancellation, instead of the old PendingCount==0 + // inference latching a false "committed". + if (pending.Portal.Present) + { + _pending = null; + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: "WatchDied", + portalGeneration: pending.Portal.RevealGeneration, + teleportSequence: pending.Portal.TeleportSequence, + destinationCell: pending.Portal.Projection.DestinationCell, + resolvedCell: pending.Record.FullCellId, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return; + } + // The funnel owns what happens next, including this packet's + // still-unsent position event (retail acks whether or not the + // placement took — see SettlePending). SettlePending( pending.Record, pending.Token, @@ -493,14 +1076,54 @@ public sealed class RuntimeAcceptedPositionDriveController if (setPosition.IsPlacementCurrent(pending.Token)) { - _ = SubmitAndResolve(pending.Record, pending.Token, pending.Route); + if (pending.Portal.Present + && !IsPortalAuthorityCurrent(pending.Portal)) + { + // A2/D-T2.4: unlike the AwaitingCommitWake branch above, + // this retry has NOT submitted yet — re-validating here + // genuinely prevents a stale commit rather than only + // suppressing its suffix. + _pending = null; + CancelToken(setPosition, pending.Token); + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: "AbandonedAtWake", + portalGeneration: pending.Portal.RevealGeneration, + teleportSequence: pending.Portal.TeleportSequence, + destinationCell: pending.Portal.Projection.DestinationCell, + resolvedCell: pending.Record.FullCellId, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return; + } + _ = pending.Portal.Present + ? SubmitAndResolvePortal( + pending.Record, pending.Token, pending.Route, pending.Portal) + : SubmitAndResolve(pending.Record, pending.Token, pending.Route); return; } - // The prepare-retry operation died the same way. (A re-issue retry - // marker also lands here, carrying PositionEventOwed: false — its - // packet's placement was never begun, so its ack belongs to the - // eventual re-issue's terminal outcome.) + // The prepare-retry operation died the same way. B1/N1: no commit, + // no _lastCommittedPortal write — see the watch-died branch above. + if (pending.Portal.Present) + { + _pending = null; + PhysicsDiagnostics.LogLocalTeleportArrival( + cause: "portal", + placementStatus: "PrepareRetryLost", + portalGeneration: pending.Portal.RevealGeneration, + teleportSequence: pending.Portal.TeleportSequence, + destinationCell: pending.Portal.Projection.DestinationCell, + resolvedCell: pending.Record.FullCellId, + hookTailRan: false, + leashArmed: false, + autorunCancelled: false); + return; + } + // (A re-issue retry marker also lands here, carrying + // PositionEventOwed: false — its packet's placement was never begun, + // so its ack belongs to the eventual re-issue's terminal outcome.) SettlePending( pending.Record, pending.Token, diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index ce19cd37..8486fab5 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -39,9 +39,18 @@ public interface IRuntimeDirectWorldProjection void BeginTeleport(); + /// + /// C4 route 3 (D-T6): is the SAME host token + /// + /// just registered via TryRegisterHostProjection — the producer's + /// generation/sequence/projection are all already in scope here, so no + /// new WorldRevealCoordinator-style exposure is needed on this + /// side either. + /// RuntimeDestinationReadiness PrepareDestination( long revealGeneration, - RuntimeTeleportDestination destination); + RuntimeTeleportDestination destination, + RuntimeWorldHostProjectionToken portal); } /// @@ -490,6 +499,43 @@ public sealed class RuntimeLiveEntitySessionController acknowledgeProjection: null, out _); + /// + /// A1/A3 review fix (2026-08-05): the generation/destination/projection + /// of an accepted portal reveal that registered its host projection but + /// has not yet actually placed the local player. Headless is + /// message-driven, not per-frame — used + /// to run the ENTIRE completion sequence (readiness ack, materialized + /// ack, complete, LoginComplete, EndTeleport) unconditionally in one + /// synchronous call, discarding the canonical portal arm's own status + /// (architecture review A3). A DeferredCell park is a NORMAL + /// headless outcome — 's + /// doc explains why the narrow collision window makes a park real + /// rather than a dead end — so this field lets + /// retry on the host's own per-tick + /// cadence (HeadlessSessionHost.Tick) instead of either + /// completing a materialization that never happened or throwing on + /// every ordinary "destination not resident yet" park. + /// + private (long Generation, + RuntimeTeleportDestination Destination, + RuntimeWorldHostProjectionToken Projection)? _pendingPortalCompletion; + + /// + /// B4 review fix (2026-08-05): the retry count for the CURRENT + /// , reset whenever a NEW portal + /// begins. Graphical's equivalent wait has a user-visible cue (AD-2's + /// centered wait state) when a park runs long; headless had neither a + /// cue, a bound, nor a log — an indefinitely stuck park (a destination + /// landblock whose collision generation never publishes) was silent and + /// undiagnosable. This does not make the retry fatal — K4's 30-session + /// endurance profile must survive a legitimately slow-publishing + /// landblock — it only makes a stuck park OBSERVABLE via periodic log + /// lines instead of running forever in silence. + /// + private int _pendingPortalCompletionRetryCount; + + private const int PendingPortalCompletionLogInterval = 100; + private void TryCompletePortal() { RuntimeWorldTransitState transit = _runtime.TransitOwner; @@ -517,11 +563,39 @@ public sealed class RuntimeLiveEntitySessionController projection, RuntimeWorldHostAcknowledgementStage.ProjectionRegistered); + _pendingPortalCompletion = (generation, destination, projection); + _pendingPortalCompletionRetryCount = 0; + TryAdvancePortalCompletion(); + } + + /// + /// A1/A3 review fix: the retryable second half of + /// . Attempts the canonical placement + /// (via , which owns the drive controller) + /// exactly once per call; if it has not committed yet, this returns + /// having mutated nothing beyond what the attempt itself did (a + /// DeferredCell park, safely retryable by construction — see + /// 's own + /// doc), and calls this again on the + /// next host tick. Once IsCollisionReady comes back true — which + /// only happens after a genuine Committed status — the full + /// readiness/materialized/complete/LoginComplete/EndTeleport sequence + /// runs exactly as before this fix, unconditionally, in one call. + /// + private void TryAdvancePortalCompletion() + { + if (_pendingPortalCompletion is not { } pending) + return; + (long generation, RuntimeTeleportDestination destination, + RuntimeWorldHostProjectionToken projection) = pending; + + RuntimeWorldTransitState transit = _runtime.TransitOwner; bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u; RuntimeDestinationReadiness readiness = _worldProjection?.PrepareDestination( generation, - destination) + destination, + projection) ?? new RuntimeDestinationReadiness( generation, destination.CellId, @@ -531,6 +605,32 @@ public sealed class RuntimeLiveEntitySessionController IsRenderNeighborhoodReady: true, AreCompositeTexturesReady: true, IsCollisionReady: true); + if (!readiness.IsCollisionReady) + { + // Still parked - PrepareDestination attempted (or is waiting on + // an outstanding DeferredCell wake) and has not committed yet. + // Nothing acknowledged, nothing completed; PumpPortalCompletion + // retries next tick. + // + // B4 review fix: periodic diagnostic so an indefinitely-stuck + // park is observable instead of silent. Not bounded to a throw - + // a slow-publishing landblock is a legitimate transient this + // host must ride out (N3's lesson: don't make a transient + // fatal). + _pendingPortalCompletionRetryCount++; + if (_pendingPortalCompletionRetryCount % PendingPortalCompletionLogInterval == 0) + { + _log( + $"headless: portal completion still parked after " + + $"{_pendingPortalCompletionRetryCount} retries " + + $"generation={generation} cell=0x{destination.CellId:X8}"); + } + return; + } + + _pendingPortalCompletion = null; + _pendingPortalCompletionRetryCount = 0; + if (!transit.AcknowledgeDestinationReadiness( readiness)) { @@ -581,6 +681,14 @@ public sealed class RuntimeLiveEntitySessionController + $"cell=0x{destination.CellId:X8}"); } + /// + /// A1/A3 review fix: called from HeadlessSessionHost.Tick + /// alongside HeadlessSessionWorldProjection.PumpFirstEntry — + /// retries a parked portal completion on the host's own per-tick + /// cadence. A no-op whenever nothing is pending. + /// + public void PumpPortalCompletion() => TryAdvancePortalCompletion(); + private static void Acknowledge( RuntimeWorldTransitState transit, RuntimeWorldHostProjectionToken projection, diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index 92b78533..9ed09749 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -1,15 +1,21 @@ -using System.Numerics; +using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.App.Streaming; using AcDream.App.Update; 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 AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using AcDream.Runtime.World; +using DatReaderWriter.Enums; namespace AcDream.App.Tests.Streaming; @@ -45,7 +51,7 @@ public sealed class LocalPlayerTeleportControllerTests y: 34f, z: 5f); - harness.Controller.OfferDestination( + harness.OfferDestination( destination, teleportTimestampAdvanced: true); Assert.Empty(harness.Streaming.Reservations); @@ -61,7 +67,7 @@ public sealed class LocalPlayerTeleportControllerTests Assert.True(harness.Reveal.Snapshot.IsActive); Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind); - harness.Controller.OfferDestination( + harness.OfferDestination( destination, teleportTimestampAdvanced: false); Assert.Single(harness.Streaming.Reservations); @@ -74,7 +80,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Mode.Controller = null; harness.Controller.OnTeleportStarted(2); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 2, 7f, 8f, 9f), teleportTimestampAdvanced: true); @@ -103,7 +109,7 @@ public sealed class LocalPlayerTeleportControllerTests Assert.True(harness.Controller.IsActive); harness.Mode.Controller = null; - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 14, 7f, 8f, 9f), teleportTimestampAdvanced: true); @@ -118,12 +124,25 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Single(harness.Streaming.Reservations); } + // C4 route 3 root-caused (closed): the earlier "bare-fixture InvalidData" + // diagnosis was WRONG - these never reached + // RuntimeSetPositionMoverPreparer.TryBuild at all. The real defect was in + // LocalPlayerTeleportController.TryExecuteCanonicalPortalPlacement: it + // re-read _transit.TryGetAcceptedTeleportDestination at Place time, but + // RuntimeWorldTransitState.TryBeginPortalReveal (driven by AimDestination + // via WorldRevealCoordinator.TryBeginPortal) atomically CONSUMES that one + // accepted-destination slot the instant Aim claims the reveal generation + // - so the Place-time re-read always found it empty and refused with + // "cause=host-token-unavailable" before ever building a mover. Fixed by + // caching the destination at Aim time (_pendingDestination), mirroring + // the existing _pendingCell/_pendingRotation/_pendingRevealGeneration + // pattern in the same method. Production fix, not a fixture workaround. [Fact] public void ControllerWithdrawnAfterAim_HoldsPresentationUntilModeRebuildsIt() { var harness = new Harness(); harness.Controller.OnTeleportStarted(15); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 15, 7f, 8f, 9f), teleportTimestampAdvanced: true); harness.Mode.Controller = null; @@ -131,7 +150,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); Assert.Empty(harness.Presentation.WorldReadyValues); harness.Mode.RebuildOnEnter = () => @@ -143,15 +162,28 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); Assert.NotNull(harness.Mode.Controller); - Assert.Equal(new Vector3(7f, 8f, 9f), harness.Placement.Position); + Assert.True(harness.Placement.Called); + // A6 review fix: assert the REAL canonical body resolved the + // offered destination (7,8,9), not just that the acknowledge-only + // suffix ran. + Assert.Equal( + new Vector3(7f, 8f, 9f), + harness.Movement.Controller!.Position); + Assert.Equal(0x20210001u, harness.Movement.Controller.CellId); } [Fact] public void SameLandblockDestination_DoesNotRecenterAndKeepsTranslatedPosition() { var harness = new Harness(centerX: 0x20, centerY: 0x21); + // 0x20210123 is an INDOOR cell (low word 0x0123 >= 0x0100) within the + // same landblock the constructor already committed. It still needs + // its own synthetic CellStruct - PhysicsEngine.IsSpawnCellReady + // requires DataCache.GetCellStruct to resolve for indoor cells, and + // a bare AddLandblock never populates one. + harness.AddSyntheticIndoorCell(0x20210123u); harness.Controller.OnTeleportStarted(3); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210123u, 3, 20f, 30f, 4f), teleportTimestampAdvanced: true); harness.Presentation.EmitPlaceWhenReady = true; @@ -159,21 +191,175 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); Assert.Empty(harness.Streaming.Recenters); - Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position); - Assert.Equal(0x20210123u, harness.Placement.CellId); + // C4 route 3: the canonical Runtime arm - not this fake - resolves + // and commits the destination; the acknowledge-only suffix carries + // no position/cell to capture (D-T4). The observable fact left at + // this layer is that the committed receipt reached the suffix. + Assert.True(harness.Placement.Called); + // A6/R4 review fix: assert the REAL canonical body resolved the + // offered destination, not just that the suffix ran. + Assert.Equal( + new Vector3(20f, 30f, 4f), + harness.Movement.Controller!.Position); + Assert.Equal(0x20210123u, harness.Movement.Controller.CellId); + } + + /// + /// A1/R1 review fix (2026-08-05): the retail/architecture review's own + /// required test — "refused arm -> the anim stream does not reach + /// RevealWorldViewport/FireLoginComplete with an unplaced body." Forces + /// a genuine Runtime-level Contention refusal (a competing operation + /// already owns the entity's placement token, same shape as the Runtime + /// layer's PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation) + /// and drives several ticks past where the OLD unconditional-march code + /// would have already fired LoginComplete. The player must still be + /// standing at the origin, in portal space, with the transit still + /// active — never released into the world unplaced. + /// + [Fact] + public void RefusedPlace_HoldsTheStreamAndConvergesOnlyAfterContentionClears() + { + var harness = new Harness(); + harness.Controller.OnTeleportStarted(50); + harness.OfferDestination( + Position(0x20210001u, 50, 11f, 12f, 13f), + teleportTimestampAdvanced: true); + harness.Presentation.EmitPlaceWhenReady = true; + Vector3 positionBefore = harness.Movement.Controller!.Position; + + // A competing operation already owns the entity's placement token - + // the canonical Runtime Begin call inside TryAdvancePortalCommit + // refuses with Contention, exactly like the Runtime-layer + // PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation test. + RuntimeEntityRecord record = harness.Lifetime.Entities + .TryGetActive(0x50000001u, out RuntimeEntityRecord active) + ? active + : throw new InvalidOperationException("fixture entity missing"); + RuntimeEntityPlacementToken displaced = harness.Lifetime.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(displaced.IsValid); + + // Several ticks, well past where the pre-fix code would already + // have reached PlayExitSound -> WorldFadeIn -> FireLoginComplete + // and reset the transit (the tunnel's own retail timing is 2-5s; + // this drives 3x that in 0.1s steps). + for (int i = 0; i < 100; i++) + harness.Controller.Tick(0.1f); + + Assert.False(harness.Placement.Called); + Assert.Equal(positionBefore, harness.Movement.Controller.Position); + Assert.True(harness.Controller.IsActive); + Assert.False(harness.Reveal.Snapshot.Completed); + Assert.Equal(0, harness.Session.LoginCompleteCount); + + // Release the competing operation - the SAME `dataReady` predicate + // that already held the stream in Tunnel now lets the commit + // succeed on the very next tick (D-T5 row 2's "next Tick + // re-attempts the Place edge", made real without ever touching + // TeleportAnimSequencer). + RuntimePlacementCancellationReceipt cancellation = harness.Lifetime + .Physics.SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + harness.Lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + harness.Controller.Tick(0.1f); + + Assert.True(harness.Placement.Called); + Assert.Equal( + new Vector3(11f, 12f, 13f), + harness.Movement.Controller.Position); + } + + /// + /// B1 review fix (2026-08-05): the required test #1 — a DeferredCell + /// park killed by an ORDINARY merge-time Forget (not a + /// collision-generation commit) must NOT be latched as "committed". + /// The destination landblock is never committed + /// (CommitLandblockCollision is deliberately not called), so the + /// portal parks; an unrelated ordinary accepted Position for the same + /// entity then Forgets the outstanding operation exactly like a normal + /// 5-10 Hz ACE broadcast would — RuntimeSetPositionState's own + /// doc names this the EXPECTED way a far-destination park resolves + /// without committing. Before the fix, PendingCount hitting 0 + /// made the host gate latch _placementCommitted = true, release + /// the sequencer, and run the ENTIRE completion sequence + /// (materialize/reveal/LoginComplete) against the unmoved body. + /// + [Fact] + public void ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted() + { + var harness = new Harness(); + const uint destinationLandblock = 0x40410000u; + harness.Controller.OnTeleportStarted(60); + harness.OfferDestination( + Position(destinationLandblock | 0x0001u, 60, 21f, 22f, 23f), + teleportTimestampAdvanced: true); + harness.Presentation.EmitPlaceWhenReady = true; + + // First tick: destinationLandblock's collision generation was never + // committed, so the canonical arm parks DeferredCell. + harness.Controller.Tick(0.016f); + Assert.False(harness.Placement.Called); + Assert.Equal(1, harness.AcceptedPositionDrive.PendingCount); + + // An ordinary, UNRELATED accepted Position for the same entity - + // no new teleport, just a normal broadcast - Forgets the parked + // operation the same way ACE's 5-10 Hz cadence would. + harness.MergeOrdinaryPosition( + new Vector3(48f, 49f, 50f), 0x20210001u, teleportSequence: 60); + // Forget (inside TryApplyPosition) cancels the underlying + // RuntimeSetPositionState operation immediately, but the drive's + // OWN _pending cache only notices on its next Advance() pump - the + // App host's real per-frame retry lease does this; the test drives + // it explicitly. + harness.AcceptedPositionDrive.Advance(); + Assert.Equal(0, harness.AcceptedPositionDrive.PendingCount); + + // Drive well past where the pre-fix inference would have latched + // "committed" on the very next tick and then marched to + // LoginComplete. + for (int i = 0; i < 100; i++) + harness.Controller.Tick(0.1f); + + // NOTE: the body's Position/CellId are NOT asserted unchanged here. + // The DeferredCell park's dormant stage (RuntimeSetPositionState's + // SubmitPreparedPlacementCore deferred-commit path, + // StageDormantCellFrame) already writes the staged destination onto + // the body's controller while body.InWorld stays false and the + // canonical placement has not committed - the reader-visible + // Position/CellId moving early is that benign staging mechanism, not + // evidence the placement committed (see the analogous headless test + // HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake + // for the same finding). The discriminators below - the presentation + // suffix never running, no materialize/reveal/LoginComplete, and the + // controller staying active in an incomplete transit - are what + // actually distinguish "committed" from "parked-then-forgotten". + Assert.False(harness.Placement.Called); + Assert.Equal(0, harness.Reveal.PortalMaterializationCount); + Assert.Equal(0, harness.Session.LoginCompleteCount); + Assert.True(harness.Controller.IsActive); + Assert.False(harness.Reveal.Snapshot.Completed); } [Fact] public void CrossLandblockDestination_RecentersBeforeItCanBecomeReady() { var harness = new Harness(centerX: 0x20, centerY: 0x21); + harness.CommitLandblockCollision(0x30310000u); + // 0x30310100 is an INDOOR cell (low word 0x0100 >= 0x0100); see the + // AddSyntheticIndoorCell doc comment for why CommitLandblockCollision + // alone (terrain-only) cannot make it spawn-ready. + harness.AddSyntheticIndoorCell(0x30310100u); harness.Mode.Controller!.SetPosition( Vector3.Zero, 0x20210001u, Vector3.Zero); harness.Controller.OnTeleportStarted(4); harness.Streaming.RecenterPending = true; - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x30310100u, 4, -2f, 8f, 9f), teleportTimestampAdvanced: true); harness.Presentation.EmitPlaceWhenReady = true; @@ -182,11 +368,17 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Equal((0x30, 0x31, true), Assert.Single(harness.Streaming.Recenters)); Assert.False(Assert.Single(harness.Presentation.WorldReadyValues)); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); harness.Streaming.RecenterPending = false; harness.Controller.Tick(0.016f); - Assert.Equal(new Vector3(-2f, 8f, 9f), harness.Placement.Position); + Assert.True(harness.Placement.Called); + // A6/R4 review fix: assert the REAL canonical body resolved the + // cross-landblock destination, not just that the suffix ran. + Assert.Equal( + new Vector3(-2f, 8f, 9f), + harness.Movement.Controller!.Position); + Assert.Equal(0x30310100u, harness.Movement.Controller.CellId); } [Fact] @@ -194,7 +386,7 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(worldReady: false); harness.Controller.OnTeleportStarted(5); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 5, 1f, 2f, 3f), teleportTimestampAdvanced: true); harness.Presentation.EmitPlaceWhenReady = true; @@ -202,14 +394,14 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(4.9f); Assert.False(Assert.Single(harness.Presentation.WorldReadyValues)); Assert.False(harness.Presentation.WaitCueValues[^1]); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); harness.Controller.Tick(0.1f); harness.Controller.Tick(30f); Assert.All(harness.Presentation.WorldReadyValues, Assert.False); Assert.True(harness.Presentation.WaitCueValues[^1]); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); Assert.True(harness.Reveal.WaitCueShown); } @@ -219,7 +411,7 @@ public sealed class LocalPlayerTeleportControllerTests var order = new List(); var harness = new Harness(order: order); harness.Controller.OnTeleportStarted(8); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 8, 4f, 5f, 6f), teleportTimestampAdvanced: true); order.Clear(); @@ -236,7 +428,7 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(); harness.Controller.OnTeleportStarted(81); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 81, 4f, 5f, 6f), teleportTimestampAdvanced: true); long generation = harness.Reveal.Snapshot.Generation; @@ -245,7 +437,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); Assert.Equal(0, harness.Reveal.PortalMaterializationCount); } @@ -255,7 +447,7 @@ public sealed class LocalPlayerTeleportControllerTests var order = new List(); var harness = new Harness(order: order); harness.Controller.OnTeleportStarted(9); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 9, 4f, 5f, 6f), teleportTimestampAdvanced: true); harness.Presentation.Enqueue(TeleportAnimEvent.Place); @@ -281,7 +473,7 @@ public sealed class LocalPlayerTeleportControllerTests var order = new List(); var harness = new Harness(order: order); harness.Controller.OnTeleportStarted(91); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 91, 4f, 5f, 6f), teleportTimestampAdvanced: true); harness.Streaming.ReservationEnds.Clear(); @@ -300,22 +492,35 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(); harness.Controller.OnTeleportStarted(10); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 10, 1f, 1f, 1f), teleportTimestampAdvanced: true); harness.Controller.OnTeleportStarted(11); harness.Controller.Tick(30f); - Assert.Equal(default, harness.Placement.Position); + Assert.False(harness.Placement.Called); Assert.Equal(0u, harness.Controller.ActiveDestinationCell); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 11, 2f, 2f, 2f), teleportTimestampAdvanced: true); harness.Presentation.Enqueue(TeleportAnimEvent.Place); harness.Controller.Tick(0.016f); - Assert.Equal(new Vector3(2f), harness.Placement.Position); + Assert.True(harness.Placement.Called); + // A6 review fix: this is the specific test A6 named — the removed + // assertion was the ONLY thing proving the SECOND destination + // (2,2,2) placed and not the superseded first one (1,1,1). + // Assert.True(Placement.Called) alone cannot distinguish them. X/Y + // are exact; Z settles to the Harness's uniform terrain height (5) + // because both candidate wire Z values (1 and 2) sit BELOW the + // floor and the placement resolve's anti-penetration push-out is + // instant (unlike gravity settling from ABOVE the floor, which + // takes further physics ticks this test never runs) - X/Y alone + // already discriminate (1,1) from (2,2) unambiguously. + Assert.Equal(2f, harness.Movement.Controller!.Position.X); + Assert.Equal(2f, harness.Movement.Controller.Position.Y); + Assert.Equal(0x20210001u, harness.Movement.Controller.CellId); } [Fact] @@ -323,7 +528,7 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(); harness.Controller.OnTeleportStarted(12); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 12, 1f, 2f, 3f), teleportTimestampAdvanced: true); @@ -359,7 +564,7 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(); harness.Controller.OnTeleportStarted(20); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 20, 1f, 2f, 3f), teleportTimestampAdvanced: true); harness.Streaming.ReservationEnds.Clear(); @@ -379,7 +584,7 @@ public sealed class LocalPlayerTeleportControllerTests { var harness = new Harness(); harness.Controller.OnTeleportStarted(30); - harness.Controller.OfferDestination( + harness.OfferDestination( Position(0x20210001u, 30, 1f, 2f, 3f), teleportTimestampAdvanced: true); harness.Session.OnSend = () => harness.Controller.OnTeleportStarted(31); @@ -435,14 +640,22 @@ public sealed class LocalPlayerTeleportControllerTests { Controller = new PlayerMovementController(new PhysicsEngine()), }; - controllerSlot.Controller.SetPosition(Vector3.Zero, cell, Vector3.Zero); + var position = new Vector3(12f, 24f, 6f); + Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f); + // C4 route 3: the canonical Runtime commit (RuntimeSetPositionState + // .CommitCanonical, driven by RuntimeAcceptedPositionDriveController + // .ReconcileAndAcknowledgePortal -> PlayerMovementController + // .CommitCanonicalTeleportFrame in production) already resolves the + // body/cell/orientation BEFORE this suffix runs. Reproduce that + // pre-state directly rather than re-resolving it here - Place() no + // longer resolves anything (D-T4). + controllerSlot.Controller.SetPosition(position, cell, position); + controllerSlot.Controller.SetBodyOrientation(rotation); var cameras = new ChaseCameraInputState { Legacy = new ChaseCamera(), Retail = new RetailChaseCamera(), }; - var origin = new LiveWorldOriginState(); - origin.SetPlaceholder(0x20, 0x21); var spatial = new FakeSpatialReconcile(() => new PlacementSnapshot( entity.Position, entity.ParentCellId ?? 0u, @@ -451,18 +664,14 @@ public sealed class LocalPlayerTeleportControllerTests controllerSlot.Controller.CellId, controllerSlot.Controller.BodyOrientation)); var placement = new LocalPlayerTeleportPlacement( - new PhysicsEngine(), runtime, identity, controllerSlot, new LocalPlayerPhysicsHostSlot(), cameras, - origin, spatial); - var position = new Vector3(12f, 24f, 6f); - Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f); - placement.Place(position, cell, rotation); + placement.Place(rotation); Assert.Equal(entity.Position, controllerSlot.Controller.Position); Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId); @@ -522,13 +731,18 @@ public sealed class LocalPlayerTeleportControllerTests Vector3.Zero, sourceCell, Vector3.Zero); + // C4 route 3: reproduce the canonical Runtime commit's pre-state + // (body/cell already resolved) - Place() no longer resolves it. + var destinationPosition = new Vector3(12f, 24f, 6f); + controllerSlot.Controller.SetPosition( + destinationPosition, + destinationCell, + destinationPosition); var cameras = new ChaseCameraInputState { Legacy = new ChaseCamera(), Retail = new RetailChaseCamera(), }; - var origin = new LiveWorldOriginState(); - origin.SetPlaceholder(0x30, 0x31); LiveEntityRecord? recordAtReconcile = null; var spatial = new FakeSpatialReconcile(() => { @@ -542,19 +756,14 @@ public sealed class LocalPlayerTeleportControllerTests controllerSlot.Controller.BodyOrientation); }); var placement = new LocalPlayerTeleportPlacement( - new PhysicsEngine(), runtime, identity, controllerSlot, new LocalPlayerPhysicsHostSlot(), cameras, - origin, spatial); - placement.Place( - new Vector3(12f, 24f, 6f), - destinationCell, - Quaternion.Identity); + placement.Place(Quaternion.Identity); Assert.NotNull(recordAtReconcile); uint resolvedDestinationCell = controllerSlot.Controller.CellId; @@ -652,8 +861,26 @@ public sealed class LocalPlayerTeleportControllerTests uint ControllerCell, Quaternion ControllerRotation); + /// + /// C4 route 3: the Place edge now drives a REAL + /// portal arm + /// against a REAL (bare, session-less) local-player canonical record - + /// the class this file's LocalPlayerTeleportController fake + /// wiring cannot substitute (it is a sealed Runtime type, not an + /// interface). also performs the SAME + /// upstream merge production runs before ever offering a destination + /// (RuntimeEntityObjectLifetime.TryApplyPosition, called by + /// LiveEntityNetworkUpdateController.OnPosition before + /// OfferDestination) so BeginAcceptedPlacementCore's + /// portal-vs-latest-cell gate sees the SAME destination the offer + /// carries - exactly the production ordering, not a shortcut. + /// private sealed class Harness { + private const uint PlayerGuid = 0x50000001u; + private const uint HomeCell = 0x20210001u; + private ushort _mergePositionSequence = 1; + public readonly FakeAuthority Authority = new(); public readonly FakeInput Input = new(); public readonly FakeMode Mode; @@ -664,6 +891,17 @@ public sealed class LocalPlayerTeleportControllerTests public readonly RuntimeWorldTransitState Transit; public readonly WorldRevealCoordinator Reveal; public readonly LocalPlayerTeleportController Controller; + public readonly RuntimeEntityObjectLifetime Lifetime; + public readonly RuntimeAcceptedPositionDriveController AcceptedPositionDrive; + /// + /// A6/R4/A5 review fix (2026-08-05): exposed so tests can assert the + /// REAL canonical body's resolved position/cell after a committed + /// portal placement, not just Placement.Called — the property + /// that let A6's superseded-teleport test lose its destination + /// discriminator (which of two destinations actually placed). + /// + public readonly RuntimeLocalPlayerMovementState Movement; + public IPreparedCollisionSource DiagnosticCollisionSource => new UnusedCollisionSource(); public Harness( int centerX = 0x20, @@ -688,6 +926,99 @@ public sealed class LocalPlayerTeleportControllerTests invalidateCompositeTextures: () => { }, isSpawnClaimUnhydratable: _ => false, streaming: Streaming); + + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + var heights = new byte[81]; + Array.Fill(heights, (byte)5f); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + HomeCell & 0xFFFF0000u, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + // The bare AddLandblock above only seeds the ENGINE's terrain - + // TryPrepareAndSubmitAuthoredPlacement's DeferredCell park keys + // off the SEPARATE collision-GENERATION ledger + // (RuntimeSetPositionState.BeginCollisionGeneration/ + // CommitCollisionGeneration), which a bare AddLandblock never + // touches. Commit it too, or every placement parks forever. + Lifetime.Physics.SetPosition.BeginCollisionGeneration( + HomeCell & 0xFFFF0000u, 1UL); + Lifetime.Physics.SetPosition.CommitCollisionGeneration( + HomeCell & 0xFFFF0000u, 1UL, ready: true); + Lifetime.Physics.ObserveLocalWorldFrame(HomeCell, teleportAdvanced: false); + Lifetime.BindEventContext( + static () => new RuntimeGenerationToken(1UL), + static () => 1UL); + Movement = new RuntimeLocalPlayerMovementState(); + RuntimeLocalPlayerMovementState movement = Movement; + var identity = new RuntimeLocalPlayerIdentityState + { + ServerGuid = PlayerGuid, + }; + movement.AttachPhysicsPublication(new RuntimeLocalPlayerPhysicsPublicationState( + Lifetime.Entities, Lifetime.Physics, movement, identity)); + Lifetime.LocalPlayerFirstEntry.BindPublication(movement.PhysicsPublication); + Lifetime.BindLiveInputs(() => false, () => movement.Controller?.Position); + var clock = new GameRuntimeClock(); + // C4 route 3 fixture note: RuntimeFirstEntryDriveController's + // ctor subscribes to RuntimeEntityObjectLifetime + // .BindInitialResidenceBeginNotification - it must exist BEFORE + // RegisterEntityWithInitialResidence runs, or it misses the + // notification and never learns the residence exists at all + // (production constructs it once per host session, ahead of + // every Create). + var firstEntry = new RuntimeFirstEntryDriveController( + Lifetime, + clock, + new UnusedCollisionSource(), + () => PlayerMovementConstructionOptions.Fallback, + static _ => new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless)); + // Production's OnSpawned shape: RegisterEntityWithInitialResidence + // then ApplyAcceptedSpawn (RuntimeLiveEntitySessionController.cs). + RuntimeEntityRegistrationResult registration = + Lifetime.RegisterEntityWithInitialResidence( + Spawn(PlayerGuid, HomeCell), isLocalPlayer: true); + RuntimeEntityRecord canonical = registration.Canonical + ?? throw new InvalidOperationException( + "fixture failed to register the local player"); + Lifetime.ApplyAcceptedSpawn( + canonical, + canonical.CreateIntegrationVersion, + canonical.Snapshot, + replaceGeneration: registration.Inbound.Disposition + is CreateObjectTimestampDisposition.NewGeneration); + for (int attempt = 0; attempt < 8 && firstEntry.PendingCount != 0; attempt++) + { + firstEntry.DriveAll(); + DrainPlacementFifo(); + } + Assert.Equal(0, firstEntry.PendingCount); + Assert.True( + Lifetime.Entities.TryGetActive(PlayerGuid, out RuntimeEntityRecord seeded), + "fixture failed to register the local player"); + Assert.False( + Lifetime.TryGetInitialCreateResidence(seeded, out _), + "fixture left the local player's initial-create residence open"); + + AcceptedPositionDrive = new RuntimeAcceptedPositionDriveController( + Lifetime, + clock, + new UnusedCollisionSource(), + new LocalPlayerOutboundController((_, _, _, _, _, _) => { }), + () => new RuntimeGenerationToken(1UL), + () => PlayerGuid, + () => movement.Controller, + () => false, + () => null, + () => movement); + Controller = new LocalPlayerTeleportController( Authority, Input, @@ -697,7 +1028,258 @@ public sealed class LocalPlayerTeleportControllerTests Reveal, Placement, Session, - Presentation); + Presentation, + AcceptedPositionDrive); + } + + /// + /// Merges the destination into the SAME canonical record the portal + /// arm reads from, exactly like production's upstream merge, then + /// offers it to the controller. + /// + public void OfferDestination( + RuntimeTeleportDestination destination, + bool teleportTimestampAdvanced) + { + _mergePositionSequence++; + var update = new WorldSession.EntityPositionUpdate( + destination.EntityGuid, + new CreateObject.ServerPosition( + destination.Position.ObjCellId, + destination.Position.Frame.Origin.X, + destination.Position.Frame.Origin.Y, + destination.Position.Frame.Origin.Z, + destination.Position.Frame.Orientation.W, + destination.Position.Frame.Orientation.X, + destination.Position.Frame.Orientation.Y, + destination.Position.Frame.Orientation.Z), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: _mergePositionSequence, + TeleportSequence: destination.TeleportSequence, + ForcePositionSequence: 0); + Lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + acknowledgeProjection: null, + out _, + out _, + out _); + Controller.OfferDestination(destination, teleportTimestampAdvanced); + } + + /// + /// B1 review fix test support (2026-08-05): merges an ORDINARY + /// accepted Position for the local player — no portal offer, no + /// transit involvement — the same + /// RuntimeEntityObjectLifetime.TryApplyPosition call every + /// real inbound ACE broadcast makes. Its doc names this the + /// mechanism that Forgets any outstanding SetPosition + /// operation for the entity, including a DeferredCell park — "the + /// exact far-destination case the park exists to serve". + /// + public void MergeOrdinaryPosition( + Vector3 position, uint cellId, ushort teleportSequence) + { + _mergePositionSequence++; + var update = new WorldSession.EntityPositionUpdate( + PlayerGuid, + new CreateObject.ServerPosition( + cellId, + position.X, + position.Y, + position.Z, + 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: _mergePositionSequence, + // The SAME already-accepted teleport sequence, not a + // regression - an ordinary ACE broadcast Apply during an + // active teleport still carries it unchanged; only a NEW + // portal advances it. + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + Lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + acknowledgeProjection: null, + out _, + out _, + out _); + } + + /// + /// Commits a SECOND landblock's collision so a cross-landblock + /// portal destination can resolve Committed instead of + /// parking DeferredCell - the constructor's own home + /// landblock is the only one committed by default. + /// + public void CommitLandblockCollision(uint landblockId) + { + var heights = new byte[81]; + Array.Fill(heights, (byte)5f); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + Lifetime.Physics.SetPosition.BeginCollisionGeneration(landblockId, 1UL); + Lifetime.Physics.Engine.AddLandblock( + landblockId, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime.Physics.SetPosition.CommitCollisionGeneration( + landblockId, 1UL, ready: true); + } + + /// + /// Registers a minimal synthetic indoor cell so a portal destination + /// whose low word is >= 0x0100 can resolve Committed on its + /// FIRST placement attempt instead of parking DeferredCell + /// forever. treats every + /// outdoor cell (low word < 0x0100) as trivially ready, but an + /// indoor cell requires DataCache.GetCellStruct to resolve a + /// real entry - the bare fixture's AddLandblock calls only + /// ever pass an empty CellSurface list, so without this an + /// indoor destination cell is permanently un-spawnable regardless of + /// how many times collision generation is committed for its + /// landblock. Mirrors + /// RuntimeSetPositionStateTests.AddSyntheticCell. + /// + public void AddSyntheticIndoorCell(uint envCellId) + { + Lifetime.Physics.DataCache.RegisterCellStructForTest( + envCellId, + new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + Portals = [new PortalInfo(0, 0, 0)], + CellBSP = new DatReaderWriter.Types.CellBSPTree + { + Root = new DatReaderWriter.Types.CellBSPNode + { + Type = BSPNodeType.Leaf, + }, + }, + }); + Lifetime.Physics.DataCache.CellGraph.Add( + new AcDream.Core.World.Cells.EnvCell( + envCellId, + Matrix4x4.Identity, + Matrix4x4.Identity, + Vector3.Zero, + Vector3.One, + Array.Empty(), + Array.Empty(), + seenOutside: false, + containmentBsp: null)); + } + + public void DrainPlacementFifo() + { + while (Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token)) + break; + } + } + + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) + { + var position = new CreateObject.ServerPosition( + cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + null, + [], + [], + [], + null, + null, + "teleport-controller-fixture", + null, + null, + null, + PhysicsState: physics.RawState, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class UnusedCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + AcDream.Content.Pak.PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Missing; + + public PreparedCollisionReadResult ReadGfxObjCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult ReadEnvCellTopology( + uint sourceFileId, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } } } @@ -800,22 +1382,29 @@ public sealed class LocalPlayerTeleportControllerTests } } + /// + /// C4 route 3: acknowledge-only per D-T4 - no position/cell arguments + /// (the canonical Runtime commit already resolved them; this suffix only + /// runs on a COMMITTED receipt, never authors one). + /// stands in for the deleted Position/CellId capture: it is + /// the fact that matters now (whether the arm committed and the Place + /// edge acknowledged it), never a value this fake could plausibly + /// mis-record. + /// private sealed class FakePlacement : ILocalPlayerTeleportPlacement { private readonly List _order; public FakePlacement(List order) => _order = order; - public Vector3 Position; - public uint CellId; + public bool Called; public Quaternion Rotation; public Action? OnPlace; - public void Place(Vector3 position, uint cellId, Quaternion rotation) + public void Place(Quaternion rotation) { _order.Add("placement"); - Position = position; - CellId = cellId; + Called = true; Rotation = rotation; OnPlace?.Invoke(); } diff --git a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs index f956059d..f4334976 100644 --- a/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimePlacementPresentationSinkTests.cs @@ -463,8 +463,22 @@ public sealed class RuntimePlacementPresentationSinkTests Assert.True(replacement.IsSpatiallyVisible); } + /// + /// B2 review fix (2026-08-05): a Place whose portal authority is stale + /// (the transit ended or was superseded while a park sat outstanding) + /// must be ACKNOWLEDGED-AND-IGNORED, not refused. Before the fix this + /// test asserted TryApply returns false for both the + /// superseded-host and wrong-sequence cases — but + /// RuntimePlacementProjectionSubscription.OnPlacement only calls + /// Acknowledge when TryApply returns true; a + /// false here leaves the stale receipt at the FIFO head + /// PERMANENTLY, wedging every later entity's placement receipt behind + /// it. The entity's world position must still never move to the stale + /// receipt's coordinates — "ignore" means the receipt is retired + /// without being applied, not silently accepted as real. + /// [Fact] - public void PortalPlace_RequiresExactCurrentTransitHostAndSequence() + public void PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored() { Fixture fixture = Fixture.Create(); LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell)); @@ -491,7 +505,10 @@ public sealed class RuntimePlacementPresentationSinkTests { WorldPosition = new Vector3(80f, 81f, 82f), }; - Assert.False(fixture.Sink.TryApply(in superseded)); + // Acknowledged (true) - the stale-authority Place is retired from + // the FIFO head, not left wedged - but the position never moves to + // the stale receipt's coordinates. + Assert.True(fixture.Sink.TryApply(in superseded)); Assert.Equal(current.WorldPosition, record.WorldEntity.Position); RuntimePlacementProjectionSnapshot wrongSequence = current with @@ -502,7 +519,7 @@ public sealed class RuntimePlacementPresentationSinkTests }, WorldPosition = new Vector3(90f, 91f, 92f), }; - Assert.False(fixture.Sink.TryApply(in wrongSequence)); + Assert.True(fixture.Sink.TryApply(in wrongSequence)); Assert.Equal(current.WorldPosition, record.WorldEntity.Position); } diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 075fe290..1a0ec5e9 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -383,10 +383,20 @@ public sealed class HeadlessSessionHostTests record.Snapshot, replaceGeneration: false)); var collision = new FixtureCollisionNeighborhood(); + // R5/A7 review fix (2026-08-05): the drive controller is now wired + // (was omitted in the first pass, leaving the canonical portal arm + // a no-op by construction here and dual-host parity with zero + // coverage). Mirrors production composition + // (HeadlessSessionHost.cs's own construction order): the SAME + // RuntimeAcceptedPositionDriveController drives both hosts through + // the identical TryExecuteAcceptedPortalArrival entry point. + RuntimeAcceptedPositionDriveController acceptedPositionDrive = + CreateAcceptedPositionDrive(runtime); var projection = new HeadlessSessionWorldProjection( runtime, collision, - firstEntry); + firstEntry, + acceptedPositionDrive); projection.ProjectSpawn(record, isLocalPlayer: true); PlayerMovementController controller = @@ -410,6 +420,15 @@ public sealed class HeadlessSessionHostTests projection.BeginTeleport(); Assert.Equal(PlayerState.PortalSpace, controller.State); + // R5/A7: the destination cell 0xA9B40001 is in the SAME landblock + // (0xA9B40000) whose collision generation the test already + // committed above, so the canonical portal arm resolves + // Committed synchronously - no DeferredCell park needed to exercise + // the real headless placement path. A1's headless retry loop + // (RuntimeLiveEntitySessionController.PumpPortalCompletion) is + // covered separately by + // HeadlessPortalDeferredCellCommitsOnPumpAfterCollisionGenerationWake + // below. RuntimeDestinationReadiness readiness = projection.PrepareDestination( revealGeneration: 7, @@ -422,13 +441,393 @@ public sealed class HeadlessSessionHostTests new Position( 0xA9B40001u, new Vector3(96f, 97f, 50f), - Quaternion.Identity))); + Quaternion.Identity)), + // R5/A7: a real, valid token - `default` was fine for the + // old no-op arm but RuntimePortalPlacementAuthority.IsValid + // now genuinely gates TryExecuteAcceptedPortalArrival on it. + new RuntimeWorldHostProjectionToken(7, 0xA9B40001u)); Assert.True(readiness.IsCollisionReady); Assert.False(readiness.IsUnhydratable); Assert.Equal(PlayerState.InWorld, controller.State); - Assert.Equal(3, collision.CenterCount); + Assert.Equal(2, collision.CenterCount); Assert.Equal(0xA9B40001u, collision.LastCell); + // A4/dual-host parity: the canonical placement actually committed - + // the body moved to the destination Position, not just the + // collision-neighborhood bookkeeping that CenterCount/LastCell + // alone would have proven even with the earlier no-op arm. Z + // settles 0.005 above the wire value (the foot sphere's bottom + // sits at origin + 0.475 - 0.48, LoadedSetupCollisionSource's own + // doc comment, ISSUES.md #285) - X/Y are exact, Z is asserted + // within that settle tolerance. + Assert.Equal(96f, controller.Position.X); + Assert.Equal(97f, controller.Position.Y); + Assert.Equal(50f, controller.Position.Z, 0.01f); + // The resolved outdoor sub-cell index is derived from X/Y within + // the landblock (not the wire placeholder 0xA9B40001), same as the + // FIRST ProjectPosition assertion above (":406-409") only checks + // landblock+indoor-vs-outdoor, not the exact sub-cell. + Assert.Equal(0xA9B40000u, controller.CellId & 0xFFFF0000u); + Assert.True((controller.CellId & 0xFFFFu) < 0x0100u); + } + + /// + /// A1/R5/A7 review fix (2026-08-05): headless has no per-frame anim + /// sequencer the way the graphical host does, so its OWN equivalent of + /// A1's "hold until committed" mechanism is + /// HeadlessSessionWorldProjection.PrepareDestination's + /// _awaitingPortalWake polling. This proves it end to end: a + /// destination in a landblock whose collision generation is NOT yet + /// committed parks (IsCollisionReady: false, body unmoved, no + /// throw — DeferredCell is a normal headless outcome per + /// PrepareDestination's own doc), and once the destination + /// landblock's collision generation commits, the SAME park resolves on + /// a later attempt WITHOUT a second concurrent Begin (Runtime's own + /// Begin would refuse that with Contention if this class re-attempted + /// blindly instead of polling PendingCount). + /// + [Fact] + public void HeadlessPortalPrepareDestinationParksThenCommitsOnCollisionGenerationWake() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000009u; + const uint destinationLandblock = 0xAAB40000u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var collision = new FixtureCollisionNeighborhood(); + RuntimeAcceptedPositionDriveController acceptedPositionDrive = + CreateAcceptedPositionDrive(runtime); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry, + acceptedPositionDrive); + + projection.ProjectSpawn(record, isLocalPlayer: true); + PlayerMovementController controller = + Assert.IsType( + runtime.MovementOwner.Controller); + controller.SetPosition( + new Vector3(48f, 49f, 50f), + 0xA9B40001u); + projection.ProjectPosition( + record, + isLocalPlayer: true, + PositionTimestampDisposition.Apply); + projection.BeginTeleport(); + + var destination = new RuntimeTeleportDestination( + player, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0, + new Position( + destinationLandblock | 0x0001u, + new Vector3(10f, 10f, 50f), + Quaternion.Identity)); + var projectionToken = new RuntimeWorldHostProjectionToken( + 7, destinationLandblock | 0x0001u); + + // Begin's own portal-vs-latest-cell gate (D-T5) requires the + // destination's landblock to match the record's LATEST MERGED + // Position, not just the transit's retained destination - mirror + // what the real inbound Position handler already does before + // TryCompletePortal ever runs (LiveEntityNetworkUpdateController's + // App-side equivalent). + Assert.True(runtime.EntityObjects.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + player, + new CreateObject.ServerPosition( + destination.Position.ObjCellId, + destination.Position.Frame.Origin.X, + destination.Position.Frame.Origin.Y, + destination.Position.Frame.Origin.Z, + destination.Position.Frame.Orientation.W, + destination.Position.Frame.Orientation.X, + destination.Position.Frame.Orientation.Y, + destination.Position.Frame.Orientation.Z), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 3, + TeleportSequence: destination.TeleportSequence, + ForcePositionSequence: 0), + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + acknowledgeProjection: null, + out _, + out _, + out _)); + + // First attempt: destinationLandblock's collision generation was + // never begun/committed, so the canonical arm parks DeferredCell. + // Must NOT throw (a park is normal, not an error). The dormant + // stage (RuntimeSetPositionState's SubmitPreparedPlacementCore + // deferred-commit path) already stages the body's Position/CellId + // at the destination while it waits (StageDormantCellFrame, + // body.InWorld=false) - the reader-visible Position moving early is + // that mechanism, not evidence the placement committed; only + // PlayerState/IsCollisionReady distinguish "staged" from + // "committed" here. + RuntimeDestinationReadiness parked = projection.PrepareDestination( + revealGeneration: 7, destination, projectionToken); + Assert.False(parked.IsCollisionReady); + Assert.Equal(PlayerState.PortalSpace, controller.State); + + // A SECOND attempt while still parked must not double-Begin - + // Runtime's own Begin would refuse a genuine second attempt with + // Contention, but PrepareDestination's _awaitingPortalWake polls + // PendingCount instead of re-attempting, so this must also report + // not-ready without throwing. + RuntimeDestinationReadiness stillParked = + projection.PrepareDestination( + revealGeneration: 7, destination, projectionToken); + Assert.False(stillParked.IsCollisionReady); + + // Commit the destination landblock's collision generation and pump + // the drive's wake (mirrors HeadlessSessionHost.Tick's own + // PumpFirstEntry -> _acceptedPositionDrive.Advance() ordering). + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + destinationLandblock, 1UL); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + runtime.EntityObjects.Physics.Engine.AddLandblock( + destinationLandblock, + new TerrainSurface(heights, heightTable), + [], + [], + worldOffsetX: 0f, + worldOffsetY: 0f); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + destinationLandblock, 1UL, ready: true); + // RuntimeAcceptedPositionDriveControllerTests.CommitLandblockCollision's + // exact proven-working shape: the wake path's + // resolveWorldOffsetFromRuntimeFrame requires the destination + // landblock's world-frame offset to already be resolvable. + runtime.EntityObjects.Physics.ObserveLocalWorldFrame( + destinationLandblock | 0x0001u, + teleportAdvanced: false); + // Drain the placement projection FIFO AFTER committing (exact order + // from RuntimeAcceptedPositionDriveControllerTests.DrainPlacementFifo's + // call site: commit collision -> drain FIFO -> Advance) - the + // deferred park's Withdraw notification is published as part of the + // collision-generation commit, not before it. + while (runtime.EntityObjects.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!runtime.EntityObjects.Physics.SetPosition + .AcknowledgeProjection(head.Token)) + { + break; + } + } + acceptedPositionDrive.Advance(); + + RuntimeDestinationReadiness committed = + projection.PrepareDestination( + revealGeneration: 7, destination, projectionToken); + Assert.True(committed.IsCollisionReady); + Assert.Equal(PlayerState.InWorld, controller.State); + Assert.Equal(10f, controller.Position.X); + Assert.Equal(10f, controller.Position.Y); + Assert.Equal(destinationLandblock, controller.CellId & 0xFFFF0000u); + } + + /// + /// B1 review fix (2026-08-05): headless's required test #2 — the same + /// unsound-commit-inference defect + /// + /// proves the HAPPY path for, exercised on the FORGOTTEN path instead. A + /// DeferredCell park killed by an ordinary, unrelated accepted Position + /// merge (exactly the ACE 5-10 Hz broadcast RuntimeSetPositionState's + /// own doc names as the expected way a far-destination park resolves + /// without committing) must leave PrepareDestination reporting + /// NOT ready, unchanged, and the + /// body never moved to the destination - before the fix, + /// PendingCount hitting 0 made PrepareDestination infer + /// "committed" and run the full readiness/materialize/LoginComplete + /// sequence against an unmoved body. + /// + [Fact] + public void HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000009u; + const uint destinationLandblock = 0xAAB40000u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + var collision = new FixtureCollisionNeighborhood(); + RuntimeAcceptedPositionDriveController acceptedPositionDrive = + CreateAcceptedPositionDrive(runtime); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry, + acceptedPositionDrive); + + projection.ProjectSpawn(record, isLocalPlayer: true); + PlayerMovementController controller = + Assert.IsType( + runtime.MovementOwner.Controller); + controller.SetPosition( + new Vector3(48f, 49f, 50f), + 0xA9B40001u); + projection.ProjectPosition( + record, + isLocalPlayer: true, + PositionTimestampDisposition.Apply); + projection.BeginTeleport(); + + var destination = new RuntimeTeleportDestination( + player, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0, + new Position( + destinationLandblock | 0x0001u, + new Vector3(10f, 10f, 50f), + Quaternion.Identity)); + var projectionToken = new RuntimeWorldHostProjectionToken( + 7, destinationLandblock | 0x0001u); + + Assert.True(runtime.EntityObjects.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + player, + new CreateObject.ServerPosition( + destination.Position.ObjCellId, + destination.Position.Frame.Origin.X, + destination.Position.Frame.Origin.Y, + destination.Position.Frame.Origin.Z, + destination.Position.Frame.Orientation.W, + destination.Position.Frame.Orientation.X, + destination.Position.Frame.Orientation.Y, + destination.Position.Frame.Orientation.Z), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 3, + TeleportSequence: destination.TeleportSequence, + ForcePositionSequence: 0), + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + acknowledgeProjection: null, + out _, + out _, + out _)); + + // First attempt: destinationLandblock's collision generation was + // never begun/committed, so the canonical arm parks DeferredCell. + RuntimeDestinationReadiness parked = projection.PrepareDestination( + revealGeneration: 7, destination, projectionToken); + Assert.False(parked.IsCollisionReady); + Assert.Equal(PlayerState.PortalSpace, controller.State); + Assert.Equal(1, acceptedPositionDrive.PendingCount); + + // An ordinary, UNRELATED accepted Position for the same entity - no + // new teleport, just a normal broadcast at the SAME already-accepted + // teleport sequence - Forgets the parked operation the same way + // ACE's 5-10 Hz cadence would (RuntimeSetPositionState.Forget, called + // from TryApplyPosition for every accepted, non-Rejected Position). + Assert.True(runtime.EntityObjects.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + player, + new CreateObject.ServerPosition( + 0x20210001u, 48f, 49f, 50f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 4, + TeleportSequence: destination.TeleportSequence, + ForcePositionSequence: 0), + isLocalPlayer: true, + forcePositionRotation: Quaternion.Identity, + currentLocalVelocity: Vector3.Zero, + acknowledgeProjection: null, + out _, + out _, + out _)); + // Forget (inside TryApplyPosition) cancels the underlying + // RuntimeSetPositionState operation immediately, but the drive's OWN + // _pending cache only notices on its next Advance() pump - the real + // host does this every HeadlessSessionHost.Tick via PumpFirstEntry; + // the test drives it explicitly, same as the App-level equivalent. + acceptedPositionDrive.Advance(); + Assert.Equal(0, acceptedPositionDrive.PendingCount); + + // Drive well past where the pre-fix inference would have latched + // "committed" on the very next PrepareDestination call and then + // marched to the full readiness/materialize/LoginComplete sequence. + for (int i = 0; i < 10; i++) + { + RuntimeDestinationReadiness stillNotReady = + projection.PrepareDestination( + revealGeneration: 7, destination, projectionToken); + Assert.False(stillNotReady.IsCollisionReady); + } + + Assert.Equal(PlayerState.PortalSpace, controller.State); } [Fact] diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index 08c31dab..a8a73b8f 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -1207,6 +1207,580 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests AssertConverged(runtime); } + #region C4 route 3 - portal arm + + /// + /// The main D-T2/D-T3 happy path, asserting the positive facts §8 item 2 + /// requires rather than only InWorld/clock: the body moved to the + /// resolved destination, velocity zeroed (retail + /// set_velocity(player, 0, 1) @0x004541B4), the leash re-armed + /// EXACTLY ONCE at the resolved position (Inversion A — proven by + /// pre-arming at a stale anchor first, so a stale anchor surviving would + /// fail the assertion), autorun cancelled (the PlayerTeleported + /// port), and exactly one outbound movement event with ZERO + /// AutonomousPosition packets (route's SendPositionImmediately is + /// always false). + /// + /// + /// R7 review fix (2026-08-05): retail CommandInterpreter::SendMovementEvent + /// @0x006B4680 (PlayerTeleported's tail-jump) gates on + /// autonomy_level != 0 — under server control (this class's + /// _usePositionFromServer, retail's UsePositionFromServer()) + /// retail sends nothing. Everything else about the commit (body move, + /// leash re-arm, autorun cancel) is unaffected by autonomy; only the + /// wire send is gated. + /// + [Fact] + public void PortalCommitted_UnderServerControlSendsNoMovementEvent() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + Assert.True(runtime.CharacterOwner.TrySetAutonomyLevel(0u)); + Assert.True(runtime.CharacterOwner.UsePositionFromServer); + + const ushort teleportSequence = 6; + var destinationPosition = new Vector3(31f, 33f, SpawnHeight); + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + destinationPosition, SpawnLandblock | 0x0001u, teleportSequence); + MergeAccepted(runtime, controller, destinationUpdate); + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status); + Assert.Equal(destinationPosition, controller.Position); + Assert.False(runtime.MovementOwner.AutoRunActive); + Assert.Empty(gameActions); + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + [Fact] + public void PortalCommitted_MovesBodyArmsLeashOnceCancelsAutorunAndSendsExactlyOneMovementEvent() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + + // Pre-arm the leash at a STALE anchor - a re-arm that merely leaves + // the OLD anchor in place (i.e. no re-arm at all) would fail the + // ConstraintPos assertion below. + var staleAnchor = new Position( + SpawnLandblock | 0x0001u, + new Vector3(1f, 1f, SpawnHeight), + Quaternion.Identity); + controller.PositionManager!.ConstrainTo(staleAnchor, 1f, 2f); + Assert.True(controller.PositionManager.Constraint!.IsConstrained); + + runtime.MovementOwner.Execute(RuntimeMovementCommand.ToggleRunLock); + Assert.True(runtime.MovementOwner.AutoRunActive); + + const ushort teleportSequence = 5; + var destinationPosition = new Vector3(30f, 32f, SpawnHeight); + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + destinationPosition, SpawnLandblock | 0x0001u, teleportSequence); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) = + MergeAccepted(runtime, controller, destinationUpdate); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.True(timestamps.TeleportAdvanced); + + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Committed, status); + Assert.Equal(destinationPosition, controller.Position); + Assert.Equal(Vector3.Zero, controller.BodyVelocity); + Assert.True(controller.PositionManager.Constraint!.IsConstrained); + Assert.Equal( + controller.Position, + controller.PositionManager.Constraint.ConstraintPos.Frame.Origin); + Assert.False(runtime.MovementOwner.AutoRunActive); + // Exactly one outbound wire packet total: the movement-event refresh. + // Zero AutonomousPosition - the portal route never sends one. + Assert.Single(gameActions); + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + /// + /// D-T1's own safety property, proved live: a portal authority whose + /// generation the transit no longer recognizes (this generation was + /// never begun) is structurally invalid, so the arm returns + /// NotApplicable without writing anything - the D-T5 refusal + /// shape. A superseded token is unobtainable by construction; this + /// exercises the same IsValid gate a stale re-derivation would + /// fail on. + /// + [Fact] + public void PortalProducerInvalidAuthority_ArmDoesNotRunAndNothingMutates() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + Vector3 positionBefore = controller.Position; + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + var destination = new RuntimeTeleportDestination( + PlayerGuid, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 5, + ForcePositionSequence: 0, + new Position( + SpawnLandblock | 0x0001u, + new Vector3(30f, 32f, SpawnHeight), + Quaternion.Identity)); + // Present but structurally invalid: RevealGeneration 0 fails + // RuntimePortalPlacementAuthority.IsValid outright - the exact shape + // a stale-generation TryRegisterHostProjection re-derivation refusal + // would leave the producer holding (default). + var invalidAuthority = new RuntimePortalPlacementAuthority( + Present: true, + RevealGeneration: 0, + TeleportSequence: 5, + Projection: default); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedPortalArrival(destination, invalidAuthority); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.NotApplicable, status); + Assert.Equal(positionBefore, controller.Position); + Assert.Empty(gameActions); + AssertConverged(runtime); + } + + /// + /// D-T5's genuinely new edge: transit pins the FIRST accepted + /// destination per generation, while BeginAcceptedPlacementCore + /// validates the portal's destination cell against the LATEST merged + /// snapshot. A second local Position merging a DIFFERENT landblock + /// between the offer and the Place edge makes Begin refuse. Positive + /// half: nothing mutates and the transit stays exactly as it was + /// (D-T5's "no half-state" invariant). + /// + [Fact] + public void PortalBeginCellMismatch_RefusesWithoutMutatingBodyOrTransit() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + + const ushort teleportSequence = 6; + var destinationPosition = new Vector3(30f, 32f, SpawnHeight); + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + destinationPosition, SpawnLandblock | 0x0001u, teleportSequence); + MergeAccepted(runtime, controller, destinationUpdate); + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + + // A SECOND local Position merges a DIFFERENT landblock after the + // portal offer/registration but before Place - record.Snapshot's + // latest accepted position no longer matches the portal's + // destination cell. SAME teleport sequence (an ordinary in-flight + // Apply, not a fresh teleport) so PhysicsTimestampGate admits it. + const uint otherLandblock = 0x02020000u; + (PositionTimestampDisposition secondDisposition, _) = MergeAccepted( + runtime, + controller, + PortalDestinationUpdate( + new Vector3(1f, 1f, SpawnHeight), + otherLandblock | 0x0001u, + teleportSequence, + positionSequence: 3)); + Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition); + Vector3 positionBefore = controller.Position; + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status); + Assert.Equal(positionBefore, controller.Position); + Assert.Empty(gameActions); + // The transit is untouched by the refusal - still active, still + // holding the SAME accepted destination, not cancelled. + Assert.True(runtime.TransitOwner.IsTeleportActive); + Assert.False(runtime.TransitOwner.Snapshot.Cancelled); + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + [Fact] + public void PortalContention_WhenTheEntityAlreadyOwnsAnActiveOperation() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + Vector3 positionBefore = controller.Position; + + const ushort teleportSequence = 7; + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + new Vector3(30f, 32f, SpawnHeight), SpawnLandblock | 0x0001u, teleportSequence); + MergeAccepted(runtime, controller, destinationUpdate); + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, SpawnLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + + RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics + .SetPosition.TryBeginExclusiveAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(displaced.IsValid); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + RuntimeAcceptedPositionExecutionStatus status = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.Contention, status); + Assert.Equal(positionBefore, controller.Position); + Assert.Empty(gameActions); + + RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects + .Physics.SetPosition.ForgetExactPlacement(displaced); + if (cancellation.IsValid) + { + runtime.EntityObjects.Physics.SetPosition + .PublishCancellation(cancellation); + } + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + /// + /// D-T2.4: a portal DeferredCell park reuses the SAME retained-operation + /// machinery the force arm uses, but on wake it commits WITHOUT the force + /// funnel's re-issue decision (trap T7 - is + /// never touched) and WITHOUT a double commit however many times + /// is + /// pumped afterward. + /// + /// Root-cause note (closed): an earlier revision of this test + /// asserted the wrong post-commit position (the FORCE arm's + /// cross-landblock +192/+192 delta) and asserted zero outbound game + /// actions after a commit that legitimately sends one. Because both + /// were WRONG, the assertion failure fired before this method's own + /// ConvergePortalHost cleanup call ever ran, which left the + /// portal's host projection unconverged - and StartedRuntime's + /// `using`-triggered Dispose() then threw "hosts=1, pending=1" + /// while unwinding, MASKING the real (first) failure as a teardown + /// defect. The actual cause: an accepted Position merge with + /// TeleportAdvanced (this is one - #283) rebases + /// RuntimePhysicsState's world-frame center onto the DESTINATION + /// landblock immediately, before the placement itself ever resolves - + /// exactly as ObserveLocalWorldFrame's doc comment states ("only + /// an accepted teleport moves it afterward"). So by the time this + /// deferred park's retry runs, the destination landblock IS the frame + /// center and the correct expected offset is zero, not the FORCE + /// arm's stale-frame cross-landblock delta. No production code + /// changed to fix this; both fixes were test-assertion corrections. + /// + [Fact] + public void PortalDeferredCell_ParksThenCommitsExactlyOnceOnTheCollisionGenerationWake() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive(runtime, out List gameActions); + + const uint deferredLandblock = 0x02020000u; + var deferredPosition = new Vector3(10f, 10f, SpawnHeight); + const ushort teleportSequence = 8; + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + deferredPosition, deferredLandblock | 0x0001u, teleportSequence); + (PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps mergeTimestamps) = + MergeAccepted(runtime, controller, destinationUpdate); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + // #283: RuntimePhysicsState.ObserveLocalWorldFrame rebases the world + // frame center to the DESTINATION landblock the instant an accepted + // Position merges with TeleportAdvanced (RuntimeEntityObjectLifetime + // .TryApplyPosition calls it with teleportAdvanced: timestamps + // .TeleportAdvanced) - by design, "only an accepted teleport moves + // it afterward" (RuntimePhysicsState.cs doc comment). This merge IS + // that accepted teleport, so by the time the placement itself runs, + // TryGetWorldFrameOffset(deferredLandblock) is already (0,0) - the + // frame is centered ON the destination, not still on SpawnLandblock. + Assert.True(mergeTimestamps.TeleportAdvanced); + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + + RuntimeAcceptedPositionExecutionStatus parked = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked); + Assert.Empty(gameActions); + Assert.Equal(1, drive.PendingCount); + + drive.Advance(); + Assert.Equal(1, drive.PendingCount); + + CommitLandblockCollision(runtime, deferredLandblock); + DrainPlacementFifo(runtime); + drive.Advance(); + + Assert.Equal(0, drive.PendingCount); + // Zero offset, not +192/+192: the accepted-teleport merge above + // already rebased the world frame onto deferredLandblock (#283), + // so the placement resolves in a frame ALREADY centered on the + // destination. Asserting the old cross-landblock delta here would + // be asserting a stale frame the merge already retired. + Assert.Equal(deferredPosition, controller.Position); + + // The committed portal placement sends its ONE outbound movement + // event exactly like PortalCommitted_* asserts (D-T3's + // PlayerTeleported port; never an AutonomousPosition - + // SendPositionImmediately is always false for the portal route). + Assert.Single(gameActions); + + // No re-issue path exists for a portal pending (trap T7): further + // pumps are pure no-ops, never a second commit or a stray ack. + drive.Advance(); + drive.Advance(); + Assert.Single(gameActions); + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + /// + /// A2/D-T2.4 review fix (2026-08-05): the wake path must re-validate + /// the portal authority before committing, so a park that resolves + /// AFTER something has made the authority stale (transit ended or was + /// superseded) does not run the reconcile/ack suffix — architecture + /// review A2's FIFO-wedge shape ("a Place receipt whose Token.Portal + /// still names the ended reveal"). The stub predicate below always + /// reports stale, standing in for that condition without needing to + /// actually end the transit mid-park. + /// + [Fact] + public void PortalDeferredCell_WakeAbandonsInsteadOfReconcilingWhenAuthorityWentStale() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + (RuntimeEntityRecord record, PlayerMovementController controller) = + EnterLocalPlayer(runtime); + RuntimeAcceptedPositionDriveController drive = + CreateAcceptedPositionDrive( + runtime, + out List gameActions, + isPortalAuthorityCurrent: static _ => false); + + const uint deferredLandblock = 0x02020000u; + var deferredPosition = new Vector3(10f, 10f, SpawnHeight); + const ushort teleportSequence = 9; + WorldSession.EntityPositionUpdate destinationUpdate = PortalDestinationUpdate( + deferredPosition, deferredLandblock | 0x0001u, teleportSequence); + MergeAccepted(runtime, controller, destinationUpdate); + (RuntimePortalPlacementAuthority portal, RuntimeTeleportDestination destination) = + BeginPortal(runtime, deferredLandblock | 0x0001u, teleportSequence, destinationUpdate); + // B8/A8 review fix: guarantees ConvergePortalHost runs even if an + // assertion below fails first - see PortalHostConvergenceGuard's doc. + using var portalHostGuard = new PortalHostConvergenceGuard( + runtime, portal.RevealGeneration, portal.Projection); + + RuntimeAcceptedPositionExecutionStatus parked = + drive.TryExecuteAcceptedPortalArrival(destination, portal); + Assert.Equal(RuntimeAcceptedPositionExecutionStatus.DeferredCell, parked); + Assert.Equal(1, drive.PendingCount); + + CommitLandblockCollision(runtime, deferredLandblock); + DrainPlacementFifo(runtime); + drive.Advance(); + + // The park resolved (PendingCount converges to zero either way — + // the wake always retires its retained operation) but because the + // stub reports the authority stale, NO reconcile/ack ran: zero + // outbound movement events, autorun untouched. The underlying body + // commit (RetryDeferred) is a SEPARATE, asynchronous mechanism this + // class cannot prevent (see the class doc on + // IsPortalAuthorityCurrent) — it still lands. + Assert.Equal(0, drive.PendingCount); + Assert.Empty(gameActions); + Assert.Equal(deferredPosition, controller.Position); + + ConvergePortalHost(runtime, portal.RevealGeneration, portal.Projection); + AssertConverged(runtime); + } + + private static WorldSession.EntityPositionUpdate PortalDestinationUpdate( + Vector3 position, + uint landblockId, + ushort teleportSequence, + ushort positionSequence = 2) => + new( + PlayerGuid, + new CreateObject.ServerPosition( + landblockId, + position.X, + position.Y, + position.Z, + 1f, + 0f, + 0f, + 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + + /// + /// Mirrors 's + /// shape against directly - F751 → + /// offer → begin reveal → register the host token exactly like the + /// graphical/headless producers (D-T1). + /// + private static (RuntimePortalPlacementAuthority Portal, RuntimeTeleportDestination Destination) + BeginPortal( + GameRuntime runtime, + uint destinationCell, + ushort teleportSequence, + in WorldSession.EntityPositionUpdate destinationUpdate) + { + RuntimeWorldTransitState transit = runtime.TransitOwner; + Assert.True(transit.TryQueueTeleportStart(teleportSequence)); + Assert.True(transit.ActivateQueuedTeleport()); + var destination = new RuntimeTeleportDestination( + PlayerGuid, + InstanceSequence: 1, + PositionSequence: destinationUpdate.PositionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0, + new Position( + destinationCell, + new Vector3( + destinationUpdate.Position.PositionX, + destinationUpdate.Position.PositionY, + destinationUpdate.Position.PositionZ), + Quaternion.Identity)); + Assert.True(transit.OfferTeleportDestination( + destination, + teleportTimestampAdvanced: true)); + Assert.True(transit.TryBeginPortalReveal( + teleportSequence, + destinationCell, + out long generation)); + Assert.True(transit.TryRegisterHostProjection( + generation, + destinationCell, + out RuntimeWorldHostProjectionToken host)); + return ( + new RuntimePortalPlacementAuthority(true, generation, teleportSequence, host), + destination); + } + + /// + /// Test-only host-side convergence: cancels the reveal generation and + /// drains the 4-stage host acknowledgement suffix + /// ('s + /// SimulationReleaseProjected + DestinationReservationReleased + + /// TerminalProjected) so + /// does not throw during StartedRuntime.Dispose() teardown. A REAL + /// host (graphical/headless) always runs this suffix itself + /// (LocalPlayerTeleportController.ResetTransit / + /// RuntimeLiveEntitySessionController.TryCompletePortal's + /// Complete path); this fixture stands in for that host exactly + /// like DrainPlacementFifo stands in for the placement + /// subscription. + /// + private static void ConvergePortalHost( + GameRuntime runtime, + long generation, + RuntimeWorldHostProjectionToken projection) + { + RuntimeWorldTransitState transit = runtime.TransitOwner; + if (!transit.Snapshot.Cancelled && !transit.Snapshot.Completed) + transit.Cancel(generation); + transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement( + projection, RuntimeWorldHostAcknowledgementStage.SimulationReleaseProjected)); + transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement( + projection, RuntimeWorldHostAcknowledgementStage.DestinationReservationReleased)); + transit.AcknowledgeHostProjection(new RuntimeWorldHostAcknowledgement( + projection, RuntimeWorldHostAcknowledgementStage.TerminalProjected)); + transit.EndTeleport(); + } + + /// + /// B8/A8 review fix (2026-08-05): a plain trailing-statement call to + /// is masked whenever an assertion + /// EARLIER in the same test body fails first - the real failure never + /// reaches xUnit because StartedRuntime.Dispose()'s own + /// RuntimeWorldTransitState.ResetSession throws a SECOND, + /// unrelated-looking "hosts=1, pending=1" exception while unwinding, + /// which is the one that actually surfaces. A using var local of + /// this guard, declared immediately after + /// returns, disposes on EVERY exit path (normal return AND exception + /// unwind) via C#'s own using semantics - equivalent to a + /// try/finally wrapping the rest of the method without the nesting. + /// is idempotent against an already- + /// converged host (every step it performs is a no-op past the first + /// successful run), so a test that ALSO calls it explicitly on its own + /// success path is safe to leave as-is; this guard exists purely to + /// guarantee the call still happens when that explicit call is never + /// reached. + /// + private readonly struct PortalHostConvergenceGuard : IDisposable + { + private readonly GameRuntime _runtime; + private readonly long _generation; + private readonly RuntimeWorldHostProjectionToken _projection; + + public PortalHostConvergenceGuard( + GameRuntime runtime, + long generation, + RuntimeWorldHostProjectionToken projection) + { + _runtime = runtime; + _generation = generation; + _projection = projection; + } + + public void Dispose() => + ConvergePortalHost(_runtime, _generation, _projection); + } + + #endregion + private static void AssertConverged(GameRuntime runtime) { RuntimeEntityObjectOwnershipSnapshot ownership = @@ -1353,7 +1927,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests /// private static RuntimeAcceptedPositionDriveController CreateAcceptedPositionDrive( GameRuntime runtime, - out List gameActions) + out List gameActions, + Func? isPortalAuthorityCurrent = null) { var captured = new List(); gameActions = captured; @@ -1372,7 +1947,15 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests () => runtime.PlayerIdentity.ServerGuid, () => runtime.MovementOwner.Controller, () => runtime.CharacterOwner.UsePositionFromServer, - () => liveSession); + () => liveSession, + // C4 route 3: the portal arm's PlayerTeleported port needs the + // autorun latch owner. Unused on the force arm this factory has + // always served, so every existing route-2 test is unaffected. + () => runtime.MovementOwner, + // A2/D-T2.4 review fix (2026-08-05): defaults to null (existing + // callers unaffected - every retained portal pending is treated + // as current, today's unconditional behaviour). + isPortalAuthorityCurrent); } /// @@ -1614,7 +2197,8 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests public RuntimeDestinationReadiness PrepareDestination( long revealGeneration, - RuntimeTeleportDestination destination) => + RuntimeTeleportDestination destination, + RuntimeWorldHostProjectionToken portal) => new( revealGeneration, destination.CellId, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 24916a45..656aa0d3 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -901,7 +901,8 @@ public sealed class RuntimeLiveEntitySessionControllerTests public RuntimeDestinationReadiness PrepareDestination( long revealGeneration, - RuntimeTeleportDestination destination) + RuntimeTeleportDestination destination, + RuntimeWorldHostProjectionToken portal) { PrepareCount++; LastDestination = destination;