From ff100cf33f04150ebd73003589d1f9175df00433 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Aug 2026 22:36:31 +0200 Subject: [PATCH] fix(runtime): give the no-window host a post-merge canonical cell commit (D1, AD-60/AD-64, AP-146/#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C5b (735f0a72) made the steady-state accepted-Position merge stop writing residency. That is retail-correct — HandleReceivedPosition @0x00453FD0 reads the wire objcell_id into a local and never assigns the object's cell — and it stays. What C5b did not account for is that its replacement writers both live in AcDream.App: the OnPosition prologue rebucket (AD-60's W2) and the post-routing wire-cell adopt (W3, AP-135). The two hosts run parallel, non-shared inbound routes. LiveEntitySessionController -> LiveEntityNetworkUpdateController.OnPosition is graphical-only; RuntimeLiveEntitySessionController.OnPositionUpdated is the no-window route and is constructed only at HeadlessSessionHost.cs:682. So AcDream.Headless had NO post-merge cell writer at all. Every remote's FullCellId was written at create/placement and then frozen for the session — and RuntimeEntityObjectViews .Snapshot projects exactly that field as RuntimeEntitySnapshot.CellId, i.e. every bot's entire world view. The local player lost one of AP-146's three refresh edges, which matters beyond cosmetics: RuntimeSetPositionState .IsAffectedCollisionResident reads FullCellId to pick which bodies a landblock retirement parks, so a bot running A->B without teleporting would have retired A while parking a body physically in B. The fix, in three parts: 1. RuntimeEntityObjectLifetime.CommitWireCellRebucket — a new Runtime owner for the committed VALUE, extracted verbatim from LiveEntityRuntime .RebucketLiveEntity. This is also the root-cause fix for the layering inversion the review found: AD-60 was documenting its own correctness by naming an App class the Runtime assembly cannot reference. Behaviour on the graphical side is unchanged — record.FullCellId is a proxy for record.Canonical.FullCellId, which is the record the callee reads, and the commit is still CommitRebucket. Verified load-bearing for BOTH hosts: sabotaging the preserve branch reddens the graphical LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell as well as the new headless assertion. 2. RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell — the no-window W2, under the same reachability rules the graphical route applies: Rejected writes nothing (the shape the App authority gate produces by returning false); a bound-projectile packet writes nothing (routed by the graphical host through the canonical projectile placement owner, which returns before W2); an active initial-create residence writes nothing (RebucketLiveEntity's own early return — while the lease is live the SetPosition conductor is the sole cell authority); a local ForcePosition writes only when the accepted-Position drive declined it (NotApplicable), because a handled force is placement-receipt-authoritative. W2/W3 themselves are untouched. 3. On the committed value (the landblock-vs-cell trap). RebucketLiveEntity's preserve branch fires on a LANDBLOCK-shaped id — low 16 bits 0xFFFF — and exists for LocalPlayerProjectionController.Project, the per-frame local movement caller that emits exactly that shape. An inbound wire objcell_id is never landblock-shaped, so on the accepted-Position route the branch is not taken and the exact wire cell is committed. That is what W2 commits today and what this now commits; the no-window host has no per-frame caller at all. Ordering is matched, not improved on: the force drive submits its placement before the commit, so its first submit still reads the pre-commit FullCellId — AP-138's amended route-2 CurrentCellId measurement. Bookkeeping in this commit: - AD-60 corrected. Its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive; the entire no-window host belonged in it. 23aa62f2's W2/W3-redundancy measurement is preserved verbatim. - AP-146 and #320 amended the same way — their three-edge list was written from the graphical host and silently assumed both hosts shared it. The no-window host had two of three; it now has all three. - AD-64 filed: the reachability decision is now expressed once per host. The value is single-sourced; the gate set is not. - #324 filed: unifying the two session controllers is the genuinely correct fix and is campaign-sized (presentation recovery, hydration, the equipped-child renderer, and the remote/projectile routing arms only one host has). Not attempted here, per the fix brief. Gates. Release build 0 errors. Complete suite 11,141 passed / 4 skipped / 0 failed, against the 11,134 / 4 / 0 baseline at 23aa62f2 — net +7, exactly the 7 tests added. Eight sabotages verified, each red on at least one discriminating test and green when reverted: remote commit removed (2 Runtime + the end-to-end Headless test); local ordinary commit removed; local NotApplicable-force commit removed; force commit made unconditional; residence gate removed; missile gate removed; Rejected gate removed; preserve branch broken (red on both hosts). Co-Authored-By: Claude Opus 4.8 --- docs/ISSUES.md | 89 ++++++ .../retail-divergence-register.md | 7 +- src/AcDream.App/World/LiveEntityRuntime.cs | 24 +- .../Entities/RuntimeEntityObjectLifetime.cs | 95 +++++- .../RuntimeLiveEntitySessionController.cs | 91 +++++- .../HeadlessSessionHostTests.cs | 119 ++++++++ .../HeadlessSessionIsolationTests.cs | 59 ++++ ...RuntimeLiveEntitySessionControllerTests.cs | 284 ++++++++++++++++++ 8 files changed, 746 insertions(+), 22 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ea5c425b..6e28f872 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,68 @@ 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. +## #324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes + +**Status:** OPEN +**Severity:** MEDIUM (no live symptom today; it is the structure that PRODUCED +D1, and it will produce the next one) +**Filed:** 2026-08-05, in the C5b architecture-review D1 fix commit, per that +fix's brief ("if the correct answer is to unify the two session controllers, +say so and file it rather than attempting it here") +**Component:** runtime / session routing / host structure + +**Description.** Two inbound entity routes exist and neither is derived from +the other: + +- graphical: `src/AcDream.App/Net/LiveEntitySessionController.cs` → + `LiveEntityNetworkUpdateController.OnPosition` (and siblings) +- no-window: `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` + (`OnPositionUpdated` and siblings), constructed only at + `src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:682` + +They share the canonical Runtime owners underneath (Slice J's whole point) but +NOT the routing decisions on top: which packet shapes reach which owner, in +what order, under what gates. Every canonical rule expressed in the graphical +route's control flow has to be re-derived by hand for the other, and nothing +enforces that it was. + +**Why this is filed as its own issue rather than fixed inline.** This is the +structural cause of defect D1 from the C5b architecture review (both reviewers +found it independently). C5b made the steady-state Position merge stop writing +residency — retail-correct — and moved the write to the `OnPosition` +prologue rebucket. That rebucket is graphical-only, so the no-window host +silently lost canonical cell tracking for every entity: remotes froze at their +placement cell for the whole session, and the local player lost one of +AP-146's three refresh edges. Nothing failed; the host just stopped being +right. The D1 fix gives the no-window route its own commit over a shared +Runtime value owner and files the residual duplication at AD-64 — it does not +remove the class. + +**What unification has to reconcile (why it is campaign-sized, not a slice):** + +1. The graphical route performs presentation recovery the no-window route has + no analogue for (`RequiresSpatialProjectionRecovery`, the equipped-child + `ChildUnparentDisposition` arm, `LiveEntityHydrationController`). +2. The graphical route performs remote contact routing, far-snap/teleport + placement arms, and projectile routing; the no-window route performs none + of them and returns early for `!isLocal`. Unifying means deciding whether + the no-window host GAINS those arms (a behaviour change with its own gate) + or whether the shared route is parameterized over them. +3. Ordering constraints are load-bearing and already documented as + measurements, not intentions — AP-138's route-2 first-submit + `CurrentCellId` observation depends on the force drive submitting BEFORE + the wire-cell commit, and AD-60/AP-147 on the merge publishing before the + rebucket. A unified route must preserve each, per host. +4. `LiveEntityRuntime`'s spatial/presentation half and its canonical half are + currently interleaved in one method (`RebucketLiveEntity`); the D1 fix + split out the canonical value derivation, but the residence gate, the + object-clock enter-world rebase, and the visibility publication are still + entangled with the bucket move. + +**Acceptance:** one route object owns the inbound decision set for both hosts, +with presentation and routing supplied as collaborators; AD-64 is deleted in +the same commit; the eight D1 sabotages still discriminate. + ## #320 — The local player's canonical cell does not track ordinary movement (follow-up from #319) **Status:** OPEN @@ -61,6 +123,33 @@ survey: a local **ForcePosition** returns before that tail, so its residency is now placement-receipt-authoritative — a refused or contended force writes no cell at all (retail's own shape; AD-62). +**Corrected 2026-08-05 by the C5b architecture review's D1 fix.** The +three-edge enumeration above was written from the graphical host and silently +assumed both hosts shared it. They do not. `AcDream.App` and +`AcDream.Headless` run parallel, non-shared inbound routes +(`LiveEntitySessionController` → `LiveEntityNetworkUpdateController.OnPosition` +versus `RuntimeLiveEntitySessionController.OnPositionUpdated`), and C5b's +replacement writer lived only in the former — so the no-window host had only +TWO of the three edges, login activation and the teleport/portal commit, and +its remotes' cells were frozen from placement onward as well. That is fixed: +`RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` now commits the +same value through a shared Runtime owner, +`RuntimeEntityObjectLifetime.CommitWireCellRebucket`, which is also where the +landblock-preserve branch this issue's item 1 is about now lives (it moved +verbatim out of `LiveEntityRuntime.cs:935-938`; update that citation when +reading item 1). The reachability duplication the fix leaves behind is filed +at AD-64, and controller unification at #324. + +**What this changes for item 6, the unresolved first verification step.** It +does not answer it, but it removes a strictly-worse case that was hiding +underneath it: before the fix, a no-window bot lacked the inbound-Position +edge entirely, so a bot running A→B without ever teleporting kept +`FullCellId` at A for the whole session — retiring A would park a body that +is physically in B, and retiring B would miss it. Both hosts now refresh at +ACE's 5-10 Hz Position cadence. The question item 6 actually asks — whether a +stale-cell landblock retirement can sweep a spatial-root local player — is +unchanged and still open. + **Why this is not #319's blast radius.** #319's fix makes a player-parented equipped child inherit the parent's (the player's) canonical cell EXACTLY — an equality invariant, not a freshness one. The child is stale-but-equal diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1d2f7603..2056a808 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) — 47 active rows (AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; 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) +## 2. Adaptation (AD) — 48 active rows (AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; 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 @@ -152,10 +152,11 @@ readiness/requeue adaptation. See | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | | AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | -| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). LEGACY HALF RETIRED 2026-08-05 (C5b, #275); row REWRITTEN rather than deleted, because wire-cell channels SURVIVE outside the merge and a silent whole-row deletion would hide them.** No Position merge commits residency any more: both the executor's `ApplyPositionAction` and the steady-state `RuntimeEntityObjectLifetime.TryApplyPosition` refresh `canonical.Snapshot.Position` with the wire pose while withholding the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`; the steady-state site is the `RefreshSnapshot` call in `TryApplyPosition`, cite by symbol — the row's former `:1338` and the C5 scoping's `:1918` were both stale). Only a Runtime `SetPosition` commit or a simulation full-cell commit may change residency inside the merge. **The precise surviving claim: a wire Position never makes a record resident INSIDE THE MERGE OR AHEAD OF CLASSIFICATION.** Two steady-state wire-cell writers deliberately remain downstream of it, and are separately filed: **(W2)** the `OnPosition` prologue rebucket (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket` → `SetFullCell`), which runs for every classification reaching the generic tail and is ALSO the local player's own cell-freshness path — cross-filed at AP-146/#320, and deliberately NOT gated, since gating it would freeze the player's canonical cell between teleports and #319's child-cell equality would inherit the freeze; and **(W3)** the post-routing wire-cell adopt for non-placing arms (`TryAdoptWireCellAfterRouting`), filed at AP-135. Packets that return BEFORE W2 — the local force path, the missile arm — are placement-receipt-authoritative for residency, or unchanged at the last commit on a refused/contended force (AD-62's shapes), which is retail's own body-keeps-its-last-placed-cell behaviour. **AMENDED 2026-08-05 at the C5b architecture review (finding L1/L2), with a measurement the C5b commit did not have: on the ordinary remote tail W2 and W3 are REDUNDANT, not complementary.** Sabotaging W2 alone — either making it adopt the committed cell instead of the wire cell, or skipping the rebucket outright — leaves the entire `LiveEntityNetworkOnPositionCollapseMatrixTests` file green, because W3's `RemoteMotion.CellId` write reads through to canonical `FullCellId` via `RuntimePhysicsState.CommitCanonicalCell`, whose graphical `CellCommitted` recovery also re-installs the render bucket. Only removing BOTH channels goes red, and then exactly one test does: `LiveEntityNetworkOnPositionCollapseMatrixTests.WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell`, added at that review because C5b shipped its "production installs the bucket at W2 in the same call" claim untested (the commit message's "no fixture covers pickup at that layer" was inaccurate — that file drives the real `OnPosition` at ~26 call sites). The practical consequence: this row's W2/W3 enumeration is correct as a list of surviving channels, but neither one individually is load-bearing on the remote tail, so a future change that retires one of them will not be caught by anything except that test. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`, the same comment) | Matches retail exactly: `HandleReceivedPosition` @0x00453FD0 reads the wire `objcell_id` into a LOCAL @0x00453FE3 and never assigns the object's cell — `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. C5b evidence: `RuntimeSteadyStatePositionMergeTests.AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary` (asserted at the merge boundary, never at `OnPosition` level, where W2 legitimately re-stamps), `…ConservesOneRebucketAndOneChildPropagation` (both parent classes), and `RuntimeAcceptedPositionDriveControllerTests.ContendedForcePosition_WritesNoResidencyAnywhere`; all sabotage-verified. | If a future change passes `refreshPosition: true` at either site, a wire Position would make a cellless canonical body resident without any placement/collision commit — the classic AP-1-shaped bug this campaign closed. Conversely, gating or deleting W2 for "symmetry" freezes the local player's canonical cell between teleports. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00453FE3 the local read); `CPhysicsObj::SetPositionInternal` 0x00515BD0 → `set_cell`; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | +| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice). LEGACY HALF RETIRED 2026-08-05 (C5b, #275); row REWRITTEN rather than deleted, because wire-cell channels SURVIVE outside the merge and a silent whole-row deletion would hide them.** No Position merge commits residency any more: both the executor's `ApplyPositionAction` and the steady-state `RuntimeEntityObjectLifetime.TryApplyPosition` refresh `canonical.Snapshot.Position` with the wire pose while withholding the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`; the steady-state site is the `RefreshSnapshot` call in `TryApplyPosition`, cite by symbol — the row's former `:1338` and the C5 scoping's `:1918` were both stale). Only a Runtime `SetPosition` commit or a simulation full-cell commit may change residency inside the merge. **The precise surviving claim: a wire Position never makes a record resident INSIDE THE MERGE OR AHEAD OF CLASSIFICATION.** Two steady-state wire-cell writers deliberately remain downstream of it, and are separately filed: **(W2)** the `OnPosition` prologue rebucket (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket` → `SetFullCell`), which runs for every classification reaching the generic tail and is ALSO the local player's own cell-freshness path — cross-filed at AP-146/#320, and deliberately NOT gated, since gating it would freeze the player's canonical cell between teleports and #319's child-cell equality would inherit the freeze; and **(W3)** the post-routing wire-cell adopt for non-placing arms (`TryAdoptWireCellAfterRouting`), filed at AP-135. Packets that return BEFORE W2 — the local force path, the missile arm, and (added at the C5b architecture review's D1 fix) the local `ChildUnparentDisposition` Superseded/Pending arm — are placement-receipt-authoritative for residency, or unchanged at the last commit on a refused/contended force (AD-62's shapes), which is retail's own body-keeps-its-last-placed-cell behaviour. **CORRECTED 2026-08-05 at the D1 fix: that enumeration was presented as exhaustive and was not — the ENTIRE no-window host belonged in it.** W2 and W3 both live in `AcDream.App`, and the two hosts run parallel, non-shared inbound routes (`LiveEntitySessionController`/`LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated`), so `AcDream.Headless` had NO post-merge cell writer at all: every remote's `FullCellId` was written at create/placement and then frozen for the session, and the local player lost this row's own inbound-Position refresh edge (AP-146/#320). Fixed in that commit by giving the no-window route its own W2 over a NEW shared Runtime owner for the committed value, `RuntimeEntityObjectLifetime.CommitWireCellRebucket` — which also retires this row's layering inversion, since it no longer has to document itself by naming an App class its own assembly cannot reference. The no-window host has no W3 analogue and needs none: it performs no remote contact routing, so there is no post-routing arm to adopt a wire cell into. The duplicated REACHABILITY decision the fix leaves behind is filed at AD-64. **AMENDED 2026-08-05 at the C5b architecture review (finding L1/L2), with a measurement the C5b commit did not have: on the ordinary remote tail W2 and W3 are REDUNDANT, not complementary.** Sabotaging W2 alone — either making it adopt the committed cell instead of the wire cell, or skipping the rebucket outright — leaves the entire `LiveEntityNetworkOnPositionCollapseMatrixTests` file green, because W3's `RemoteMotion.CellId` write reads through to canonical `FullCellId` via `RuntimePhysicsState.CommitCanonicalCell`, whose graphical `CellCommitted` recovery also re-installs the render bucket. Only removing BOTH channels goes red, and then exactly one test does: `LiveEntityNetworkOnPositionCollapseMatrixTests.WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell`, added at that review because C5b shipped its "production installs the bucket at W2 in the same call" claim untested (the commit message's "no fixture covers pickup at that layer" was inaccurate — that file drives the real `OnPosition` at ~26 call sites). The practical consequence: this row's W2/W3 enumeration is correct as a list of surviving channels, but neither one individually is load-bearing on the remote tail, so a future change that retires one of them will not be caught by anything except that test. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`, the same comment; `CommitWireCellRebucket`, the shared committed-value owner added at the D1 fix); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`, the graphical W2 caller); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, the no-window W2) | Matches retail exactly: `HandleReceivedPosition` @0x00453FD0 reads the wire `objcell_id` into a LOCAL @0x00453FE3 and never assigns the object's cell — `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. C5b evidence: `RuntimeSteadyStatePositionMergeTests.AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary` (asserted at the merge boundary, never at `OnPosition` level, where W2 legitimately re-stamps), `…ConservesOneRebucketAndOneChildPropagation` (both parent classes), and `RuntimeAcceptedPositionDriveControllerTests.ContendedForcePosition_WritesNoResidencyAnywhere`; all sabotage-verified. D1-fix evidence for the no-window half: `RuntimeLiveEntitySessionControllerTests` — `AcceptedRemotePosition_AdvancesCanonicalResidencyInANoWindowHost`, `AcceptedLocalPlayerPosition_AdvancesCanonicalResidencyInANoWindowHost`, `WireCellCommit_HonoursRejection_Residence_AndTheLandblockPreserveRule`, `BoundProjectilePosition_CommitsNoWireCell_UnboundMissileDoes`; `HeadlessSessionIsolationTests.RemoteSteadyStatePositionAdvancesTheBotVisibleCell` (end to end through a real `HeadlessSessionHost`); and the two-direction `HeadlessSessionHostTests.LocalForcePosition_CommitsTheWireCellOnlyWhenTheDriveDeclined` theory, whose handled arm discriminates on a measured resolved cell (0xA9B4001C) that is neither the wire cell nor the spawn cell. Eight sabotages verified, each red on at least one of these; the shared derivation's sabotage additionally reddens the graphical `LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell`, which is what establishes that the extracted rule is the same rule both hosts run. | If a future change passes `refreshPosition: true` at either site, a wire Position would make a cellless canonical body resident without any placement/collision commit — the classic AP-1-shaped bug this campaign closed. Conversely, gating or deleting W2 for "symmetry" freezes the local player's canonical cell between teleports. And a host without W2 at all freezes EVERY entity's cell after its placement — the D1 defect: a bot's `RuntimeEntitySnapshot.CellId` never advances, and `RuntimeSetPositionState.IsAffectedCollisionResident` parks bodies against a landblock they left. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00453FE3 the local read); `CPhysicsObj::SetPositionInternal` 0x00515BD0 → `set_cell`; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | | AD-61 | **Filed 2026-08-02 (C3c review round 1).** The #270 settle-timing compression now covers the LOCAL player: `RuntimeLocalPlayerPhysicsPublicationState.SettleFirstEntryGroundContact` runs the shared `SpawnPlacementSettler` exactly once after the dormant activation's final commit (suffix-current authority only), compressing retail's first post-`enter_world` gravity frame — which grants CONTACT/ON_WALKABLE from a real touch — into the placement transaction. The legacy App-era force-seed (`Contact\|OnWalkable\|Active` in `PlayerMovementController.SetPositionCore`) still RUNS during publication-candidate preparation and is then OVERWRITTEN by the faithful activation commit + settle (it was never deleted). Caveat (review minor M2): the settler commits `settle.Position` but discards `settle.CellId` — a settle whose few-cm sweep crosses a cell boundary keeps the placement cell until the next resolve corrects it (inherited #270 semantics; ISSUES entry filed) | `src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs` (`SettleFirstEntryGroundContact`); `src/AcDream.Core/Physics/SpawnPlacementSettler.cs` (`TrySettle`); overwritten seed `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`SetPositionCore`) | Timing compression only: contact comes exclusively from the sweep's real touch (no caller-bool seeding, no forced transients), an airborne spawn stays genuinely airborne, and the overwritten force-seed leaves no observable residue past the activation commit — the committed state is exactly what retail's first gravity frame produces | A settle crossing a cell boundary reports the stale placement cell for the frames before the next resolve; a future reader trusting `SetPositionCore`'s "treat as grounded" seed comment could reintroduce the Contact-without-plane state the landing family calls unrepresentable | `CPhysicsObj::enter_world` 0x00516170; `SmartBox::HandleCreateObject` 0x00454C80 | | AD-62 | **Filed 2026-08-03 (C4 route 2, round 2); rewritten round 3.** General rule: an accepted local-player ForcePosition that this route does not carry through to a committed canonical placement is never re-applied. That half matches retail — `SmartBox::BlipPlayer` attempts the placement exactly once and never retries. What diverges is that acdream has non-commit outcomes retail cannot reach at all, because retail's world is fully resident and its placement synchronous. Round 3 narrowed the loss to the re-apply alone wherever the packet's placement was actually BEGUN: the retail position event now fires at that packet's terminal outcome whether or not the placement committed (`SettlePending`'s `positionEventOwed` path), matching `BlipPlayer` discarding `SetPositionSimple`'s `enum SetPositionError` and `HandleReceivedPosition` acking unconditionally @0x00454091. Shapes losing ONLY the re-apply: (i) the destination landblock's collision generation is unpublished so the placement parks (`DeferredCell`) and is then retired by a non-position cause (collision-generation retirement, the lost-cell deadline, `ParkCollisionResidents`) with the accepted authority unmoved — the funnel's EQUAL branch; (ii) the same park superseded by a newer ordinary `Apply` Position which now owns the pose — the ADVANCED+ordinary branch; (iii) any OTHER `PositionAuthorityVersion` advance moving the record out from under the funnel's re-issue test — `TryApplyPickup` (`RuntimeEntityObjectLifetime.cs:1116`), `CommitPositionChannelUpdate` (`:2041`), `AdvanceCreateAuthority` (`:2466`) — effectively unreachable for a live local player, but they fail silently in the same direction and the funnel cannot tell them from (ii). Shapes still losing BOTH the re-apply and the ack because no placement was ever begun for that packet: (iv) a `Contention` whose blocking operation is EXTERNAL to this drive (a concurrent portal/teleport placement owns the entity) — nothing is recorded in `_pending`, so nothing pumps it and the packet is dropped outright; (vi) a re-issue retry marker whose re-issue never manages to begin before the funnel clears it. Losing BOTH for a DIFFERENT reason — the placement WAS begun, but the descriptor was displaced before reaching its own terminal settle: (v) a packet superseded by a newer force whose own placement begins cleanly — `SettlePending` opens by nulling `_pending` without reading it, so the older descriptor's owed ack is discarded. Replaying it would be worse than losing it (a stale-sequence report carrying the newer packet's committed pose), and the displacing packet always acks, so ACE always receives a report for the newest force. The `DeferredCell` park is NOT a precondition of this row: shapes (iv)-(vi) never park. In every shape the body stays where the last successful placement left it and the next accepted Position (ACE broadcasts at 5-10 Hz) carries the corrected pose forward. | `src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs` (`SettlePending` — the single terminal-outcome funnel: its `positionEventOwed` ack and its two non-reissuing branches; and `TryExecuteAcceptedLocalPosition`'s `Contention` return) | Retail has no park and no external placement authority: `SmartBox::BlipPlayer` runs synchronously against a fully resident world, so "arrived but not yet placeable" and "another placement owns this entity" are both unrepresentable there. Those are our async collision-publication and single-placement-authority adaptations. Re-issuing a retired force instead would be worse than not: shape (ii) would stamp the force route's `Teleport\|Slide` flags and an unconditional ack onto an ordinary echo's pose while skipping the `ConstrainTo` the ordinary branch runs (`RuntimeAuthoritativePositionRouteClassifier.cs:368-388`), and shape (i) can re-issue into the same persistent cancellation cause indefinitely. The drive still owns at most one in-flight placement and still re-issues whenever the newest accepted event IS a still-unserved ForcePosition. | A server correction whose destination collision is slow to publish, or which lands while another placement authority owns the entity, can be silently skipped: the player stays at the pre-correction pose for one broadcast interval (~100-200 ms). Sustained (a slow-publishing destination correcting repeatedly) this reads as rubber-banding that does not take. In shapes (iv)-(vi) ACE additionally receives one fewer `AutonomousPosition` than retail would have sent, so the server cannot tell its force was not applied. | `SmartBox::HandleReceivedPosition` @0x00453FD0 FORCE_POSITION branch (`SendPositionEvent` @0x00454091, early return @0x0045409D); `SmartBox::BlipPlayer` @0x00453940 (discards the error, returns void); `CPhysicsObj::SetPositionSimple` @0x005162B0 (returns `enum SetPositionError`; other callers test `== OK_SPE` @0x0055605D/@0x00556021); `CommandInterpreter::SendPositionEvent` @0x006B4770 | | AD-63 | **Filed 2026-08-04 (cancelled-park presentation rollback).** When a cancelled restorable park is rolled back, the entity's presentation is restored EXCEPT the player's selection. `ParkDeferred`'s Withdraw receipt makes the host sink clear the selection if the parked entity was the selected object (`_clearSelectionForUnavailableEntity`), and the `WithdrawalRestored` receipt that rolls that withdrawal back deliberately does not re-select it. Every other registration the withdrawal removed — the graphical bucket, projection visibility, plugin world state, the world-event replay set, the effect-pose registry, the local-player shadow, the presentation visibility sinks — IS restored exactly. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryApplyWithdrawalRestoration` vs `TryPublishWithdrawal`'s `_clearSelectionForUnavailableEntity` call) | Selection is user intent, not a projection registration. Retail clears the selection when its target becomes unavailable (`SelectionChangeReason.SelectedObjectRemoved` is acdream's name for the same edge) and never re-selects on the object's behalf; re-selecting here would invent input the player did not give. Retail also cannot reach this state at all — it has no cancel for a lost-cell park (AP-136) — so there is no retail behaviour to match, only two acdream choices, and "do not act for the player" is the conservative one. | The player loses their target for the ~150 ms park window if the selected object happened to park, and must re-click it. No other state is affected: the object is visible, on the radar, collidable, and assessable again as soon as the restoration receipt drains. Retire together with AP-136 by making the park survive cancellation (issue #309), which removes the withdrawal — and therefore the selection clear — entirely. | AP-136 (the park rollback this rides on); no retail anchor — retail has no cancellable lost-cell park | +| AD-64 | **Filed 2026-08-05 at the C5b architecture review's D1 fix.** The graphical and no-window hosts run parallel, non-shared inbound entity routes — `LiveEntitySessionController` → `LiveEntityNetworkUpdateController.OnPosition` versus `RuntimeLiveEntitySessionController.OnPositionUpdated` — and AD-60's W2 wire-cell commit is therefore expressed TWICE. The committed VALUE is shared exactly (one owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, including the landblock-vs-cell preserve branch); what is duplicated is the REACHABILITY decision — which packets may reach it. The graphical host encodes that decision implicitly, as the set of early returns strewn through a 400-line `OnPosition` (authority gate on `Rejected`, the local force arm on every drive status except `NotApplicable`, the missile arm, the `ChildUnparentDisposition` Superseded/Pending arm, the initial-create residence gate inside `RebucketLiveEntity`). The no-window host encodes it explicitly, in one method, `TryCommitAcceptedWireCell`, whose gates were derived from those returns one by one. Two of the graphical gates have no no-window analogue and are deliberately absent rather than reproduced: the `ChildUnparentDisposition` arm is presentation recovery this host does not perform, and the residence gate's `MaterializationResidence is AwaitRuntimePlacement` half is App presentation bookkeeping whose no-window equivalent is unconditionally true for a residence-backed record. The no-window host also has no W3 (`TryAdoptWireCellAfterRouting`) analogue and needs none — it performs no remote contact routing at all. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the implicit gate set); `src/AcDream.App/World/LiveEntityRuntime.cs` (`RebucketLiveEntity`'s residence early return); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell`, `IsMissilePacket`) | Retail has one client and therefore one route; there is no retail shape to match, only acdream's own two-host structure. The alternative — unifying the two session controllers so the decision exists once — is the genuinely correct fix and is filed as issue #324, but it is campaign-sized: it has to reconcile presentation recovery, hydration, the equipped-child renderer, and the remote routing arms that only one of the two hosts has. Duplicating a small, individually test-gated decision is the cheaper correct thing meanwhile; duplicating it SILENTLY, which is what the pre-D1 state amounted to (one host simply had none of it), is what this row exists to stop. | The two decisions can drift: a future change to one host's reachability rules will not be caught by the other host's tests. Concretely, if the graphical route later adds an early return, the no-window host keeps committing on that packet shape, and vice versa. Bounded by the eight-sabotage gate the D1 fix left behind — every arm of `TryCommitAcceptedWireCell` and both directions of the force rule are individually red-verified — so drift shows up as a test that must be deliberately changed, not as a silent divergence. Retire with #324. | No retail anchor — acdream-only host-structure deviation. Adjacent rows: AD-60 (the W2/W3 channel list), AP-146/#320 (the local player's cell edges) | --- @@ -171,7 +172,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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. AMENDED AGAIN 2026-08-05 at the #319 fix — clause (f) added. AMENDED AGAIN 2026-08-05 at the #319 dual-review round (retail PASS, architecture FAIL/6 MAJORs) — clause (f) rewritten: the tripwire moved above the canonical commit and no longer throws (A1), and the deferred late-bind queue A1's fix text originally described was deleted per A6 (both reviews proved it production-unreachable for both producers).** 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. **(f) Filed 2026-08-05 (#319 fix).** A CreateObject-carried parent relation (the raw spawn's `Physics.Parent` field, and the same-generation `CreateParentUpdate` envelope) names the parent's GUID and location only — neither wire shape carries a parent instance sequence, matching retail's own GUID-only attach (`PhysicsDesc::get_parent_id` @0x00558a18 → `CObjectMaint::GetObjectA` @0x00558a2d → `CPhysicsObj::set_parent` @0x00558a3e; the reverse `CObjectMaint::SetChildren` @0x00509370 hash-walks by guid with a `GetNullObject` placeholder @0x005093e6 — no instance-sequence field or comparison exists anywhere in either direction). acdream's committed-relation table is nonetheless keyed by (guid, incarnation) (clause (c)'s D1/D2 requirement), so a CreateObject-carried relation must adopt SOME incarnation to file under; it now LATE-BINDS to the parent's LIVE incarnation at accept time (`EquippedChildRenderController.AcceptLateBoundCreateObjectRelation`, both the raw-CreateObject and same-generation `CreateParentUpdate` producers) rather than the previously-hardcoded 0, which silently mis-keyed every player-parented CreateObject relation (a player's `ObjectInstance` is `Character.TotalLogins`, never 0) and defeated D1/D2 for the local player's own login equipment and every remote player's observed equipment (#319). A commit-time tripwire (`ParentAttachmentState.CanCommitIncarnation`, checked BEFORE either half of the commit mutates state — architecture review A1, 2026-08-05, moved it there after the original throw-after-canonical-commit shape was shown to tear the transaction it was built to protect) refuses (logs, returns false, never throws) rather than silently filing a relation under a mismatched incarnation whenever the parent is currently addressable. **A1 also settled A6's design question**: an initial revision queued a relation whose parent was not yet addressable through a deferred/late-bind retry mechanism; both reviews independently proved that queue was structurally unreachable in production for BOTH producers (`RuntimeEntityObjectLifetime.RegisterEntityCore`'s `EnqueueDeferredCreate` gate defers the ENTIRE CreateObject, for both wire shapes, before either producer ever runs) while carrying three latent defects of its own (a missing child POSITION_TS gate, a placeholder-incarnation collision with the generation filters, unbounded accumulation) — it was deleted rather than fixed in place; the unaddressable-parent case now logs and refuses outright, matching the invariant the layer above already enforces. | `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`, `CanCommitIncarnation`, `CommitProjection`); `src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs` (`HasPartArray`); `src/AcDream.App/Rendering/EquippedChildRenderController.cs` (`AcceptLateBoundCreateObjectRelation`, `OnSpawn`, `OnCreateParentAccepted`, `PrepareAndTryRealize`); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`ResolveAndCommitChildAttachment`) | 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. Clause (f) is retail-faithful for the identical reason clauses (a)-(d) are: retail's attach has no incarnation gate on this path at all, so adopting the current holder of the guid IS the retail behavior, not an approximation of it. | 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. (f)'s risk: none identified against a well-behaved ACE — a CreateObject's parent guid always names the entity that currently holds it by construction. | `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); `PhysicsDesc::get_parent_id` 0x00558a18; `CObjectMaint::GetObjectA` 0x00558a2d; `CPhysicsObj::set_parent` 0x00558a3e; `CObjectMaint::SetChildren` 0x00509370 (`GetNullObject` placeholder @0x005093e6) | | 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-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition` generic tail's prologue rebucket after an accepted inbound Position (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket`; **amended 2026-08-05 by C5b/#275** — this writer was `RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`, i.e. the merge itself, until C5b made the merge withhold the wire cell per AD-60; a ForcePosition, which returns before this tail, is now placement-receipt-authoritative instead), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.App/World/LiveEntityRuntime.cs:935-938` (the landblock-preserve branch); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | +| AP-146 | **Filed 2026-08-05 (#319 fix, the local player's canonical cell prerequisite; follow-up filed as issue #320).** Retail writes the local player's cell on EVERY physics tick (`CPhysicsObj::SetPositionInternal` @0x00515330, unconditional for any moving body including the player). acdream's canonical `FullCellId` for the LOCAL player is written only at three edges: login activation (`RuntimeSetPositionState.cs:2741-2745`), the `OnPosition` generic tail's prologue rebucket after an accepted inbound Position (`LiveEntityNetworkUpdateController` → `LiveEntityRuntime.RebucketLiveEntity` → `RuntimeEntityObjectLifetime.CommitRebucket`; **amended 2026-08-05 by C5b/#275** — this writer was `RuntimeEntityDirectory.RefreshSnapshot` → `RuntimeEntityRecord.cs:234`, i.e. the merge itself, until C5b made the merge withhold the wire cell per AD-60; a ForcePosition, which returns before this tail, is now placement-receipt-authoritative instead), and a teleport/portal placement commit (`RuntimeSetPositionState.cs:5001-5007`; `LocalPlayerTeleportController.cs:255`). Ordinary WASD movement passes a LANDBLOCK id, not an exact cell (`LocalPlayerProjectionController.Project`, low 16 bits forced to `0xFFFF` in both branches), and `LiveEntityRuntime.cs:935-938` explicitly PRESERVES the prior canonical cell for that shape rather than writing the coarser value — so the local player's canonical cell is coarse and mostly-frozen between teleports, never per-crossing-fresh. #319's fix makes a player-parented equipped child inherit exactly this same value (D1/D2 propagate the PARENT's canonical cell to the child verbatim) — the child is stale-but-EQUAL wherever the player's own record already is, not a new staleness class. **AMENDED 2026-08-05 at the C5b architecture review's D1 fix: this three-edge enumeration was written from the graphical host and silently assumed both hosts shared it.** They do not — the two run parallel, non-shared inbound routes — and the second edge (the `OnPosition` prologue rebucket) lived in `AcDream.App`, so the no-window host had only TWO of the three, the login activation and the teleport/portal commit. It now has all three: `RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell` commits the same value through the same shared owner, `RuntimeEntityObjectLifetime.CommitWireCellRebucket`. The no-window host reaches that edge on the local ordinary (`Apply`) Position and on a `ForcePosition` the accepted-Position drive declined (`NotApplicable`), mirroring the graphical route exactly — a force the drive HANDLED stays placement-receipt-authoritative. This row's COARSENESS claim is unchanged and applies identically to both hosts: the preserve branch now lives in `CommitWireCellRebucket` rather than at `LiveEntityRuntime.cs:935-938`, and the no-window host does not even have the per-frame landblock-shaped caller that motivates it. | `src/AcDream.App/Input/LocalPlayerProjectionController.cs` (`Project`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`CommitWireCellRebucket` — the landblock-preserve branch, moved here verbatim from `LiveEntityRuntime.cs:935-938` at the D1 fix so both hosts share one rule); `src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs` (`TryCommitAcceptedWireCell` — the no-window host's inbound-Position edge); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (activation `:2741-2745`, teleport commit `:5001-5007`) | Making the local player's canonical cell track ordinary movement exactly (an exact-cell rebucket rather than the landblock-only one) is a LARGER slice than #319's key fix alone — it touches the landblock-preserve contract, `Rebucketed` delta publication cadence (today the player never publishes one during WASD), the route-2/4b-3 `PreMergeCommittedCellId` classification inputs AP-136/AP-138 spent four review rounds pinning, and the portal-space frozen-source-cell race (`LocalPlayerProjectionController.Project:100-103`). Deliberately NOT bundled into #319; filed as its own follow-up, issue #320. | The player's own render/liveness/radar/picking paths already tolerate this staleness today (proven: the player renders correctly everywhere via `Source.ParentCellId`-driven visibility, not `FullCellId`) — verified safe for the EXISTING consumer set. UNRESOLVED (this row's own open item, carried into #320): whether `RuntimeSetPositionState.IsAffectedCollisionResident`'s `ParkCollisionResidents` sweep could retire a spatial-root local player on a stale cell after a long teleport-free WASD run beyond the streaming radius — not established either way; the connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking it. If the player IS a spatial root and this is reachable, the same staleness this row accepts for render/child-inheritance would ALSO apply to collision retirement, which is a materially different risk class. **The D1 fix narrows that open item's URGENCY without answering it**: before the fix a no-window bot was strictly worse than the graphical client here, because it lacked the inbound-Position edge entirely — a bot running A→B without teleporting kept `FullCellId` at A for the whole session, so retiring A parked a body physically in B, and retiring B missed it. Both hosts now refresh on every accepted Position; what remains open is the same question this row always asked, at ACE's 5-10 Hz cadence rather than never. | `CPhysicsObj::SetPositionInternal` 0x00515330 (unconditional per-tick cell write) | | AP-147 | **Filed 2026-08-05 at the C5b architecture review (finding D3) — an unfiled delta-stream cardinality change C5b introduced, which its own conservation test could not see.** A cell-changing accepted steady-state Position now publishes **two** `RuntimeEntityDelta`s for the moved entity where it published one, and the intermediate one carries a torn cell/position pair. Pre-C5b the merge itself moved `FullCellId`, so it published `Rebucketed` and the `OnPosition` prologue rebucket's `CommitRebucket` then early-returned publish-less (`previous == fullCellId`) — stream `[Rebucketed]`. Post-C5b the merge moves nothing, so it publishes `Updated` and `CommitRebucket` publishes the `Rebucketed` — stream `[Updated, Rebucketed]`. The `Updated` element is assembled from the canonical record BETWEEN the two writes, so its `CellId` is the OLD (committed) cell while its `Position` is the NEW wire pose: a pair that did not previously exist on this stream, because pre-C5b both halves moved inside one publish. Total per packet is conserved in KIND and final VALUE — exactly one `Rebucketed`, at the same cell, from the same publisher — but not in COUNT, and not in intermediate consistency. | `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (`TryApplyPosition`'s terminal `AcknowledgeProjectionAndPublish`, and `CommitRebucket`); `src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs` (`Snapshot` — the `record.FullCellId` / `record.Snapshot.Position` pairing that makes the intermediate torn) | Retail has no delta stream at all, so there is no retail shape to match — this is acdream's own observer contract. The alternative, suppressing the merge's `Updated` when a rebucket is about to follow, is not available at that layer: the merge cannot know whether its caller will reach W2 (the local force arm, the missile arm, and the `ChildUnparentDisposition` Superseded/Pending arm all return before it), so suppressing would silently drop the pose delta on exactly the packets where it is the only one. Collapsing the merge's ternary to a constant `Updated` is likewise wrong — the retained `Rebucketed` arm has a real producer, the cancelled-park rollback inside the merge. | Any consumer that treats one accepted Position as one entity delta now sees two, and any consumer that reads `CellId` and `Position` from the SAME delta and assumes they agree can transiently pair a new position with the old cell. No production consumer identified today: `LiveEntityRuntime` and the plugin/world-event surfaces re-read canonical state rather than trusting a delta's paired fields, and the pair reconverges inside the same `OnPosition` call. A future consumer that SNAPSHOTS a delta — a recorder, a plugin, a headless bot event log — would capture the torn intermediate. Retire together with W2, if the local player's canonical cell ever becomes per-crossing-fresh (AP-146/#320) and the merge and the rebucket can be one write again. | No retail anchor — acdream-only observer contract. Evidence: `RuntimeSteadyStatePositionMergeTests.CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation` asserts the complete ordered stream `[Updated, Rebucketed]` plus both elements' `CellId`/`Position.ObjCellId`, and `RuntimeSetPositionStateTests.AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge` pins the retained arm; both sabotage-verified in both directions at the C5b review. | | ~~AP-145~~ | **RETIRED 2026-08-05 (C5a commit 1, closing #318; corrected at the architecture-review re-pass, A1/A2).** `RuntimePlacementPresentationSink.TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose(entity, entity.Position, entity.Rotation, record.FullCellId, force: true)` — the SAME publisher ordinary per-tick movement uses — instead of writing `LocalPlayerShadowState.Set` directly. `SyncPose` calls `ShadowPositionSynchronizer.Sync` → `ShadowObjectRegistry.UpdatePosition` (the real `PhysicsEngine.ShadowObjects` publish) BEFORE it records the dedup cache as its own last step, so the cache can no longer be pre-seeded ahead of the real publish. `force: true` because this is the authoritative placement commit, not an ordinary refresh — it must never be skipped by `SyncPose`'s own dedup check. **`TryPublishWithdrawal` carried the exact mirror asymmetry** (a bare `_localPlayerShadow.Clear()` with no `ShadowObjects.Suspend`, leaving a live phantom row at the park's source cell for the whole park window — the #184 shape) and is fixed in the SAME commit, same one-call shape: `_localPlayerShadowSync.Suspend(entity)`. The sink no longer holds a direct `LocalPlayerShadowState` reference at all — both halves route exclusively through the one synchronizer, which owns the cache internally. One synchronizer instance is constructed in `LivePresentationComposition.cs` (before the sink) and threaded through `LivePresentationResult` to `SessionPlayerComposition.cs`, which no longer builds its own. `#318`'s composition test (`RuntimePlacementShadowCompositionTests.cs`, 4 facts) proves: the real `ShadowObjects` registry holds a row at the destination cell (not just the cache) after a bare `Place` with no subsequent tick; the SOURCE cell's row is gone, not duplicated; a subsequent ordinary per-tick `Sync` call is a correct no-op; a `Withdraw` suspends the real registry row (not just the cache) — the source cell carries zero rows and the retained (suspendable) registration survives for a later restore; and a Place for a **registered** non-local-player entity leaves its row at the source cell and does not pollute the player's cache (route 7 P4 — the fix lives entirely inside the pre-existing player-only gate; the first version of this fact registered nothing for the child and was vacuous under the gate's own removal, corrected at the review). Sabotage-verified all four facts, both directions: reverted, each fails at its own discriminating assertion; applied, all green. | `src/AcDream.App/World/RuntimePlacementPresentationSink.cs` (`TryPublishPlace`, `TryPublishWithdrawal`); `src/AcDream.App/Composition/LivePresentationComposition.cs` (`LocalPlayerShadowSynchronizer` construction + `LivePresentationResult` field); `src/AcDream.App/Composition/SessionPlayerComposition.cs` (consumes the shared instance); `tests/AcDream.App.Tests/World/RuntimePlacementShadowCompositionTests.cs` | — | — | No retail analogue — retail has no separate shadow-cache/publish split; this was an acdream-only two-object seam (`LocalPlayerShadowState` cache + `LocalPlayerShadowSynchronizer` publisher) that a direct `.Set()`/`.Clear()` call could desynchronize from | | ~~AP-1~~ | **RETIRED 2026-08-05 (C5a deletion sweep).** "Production zero-delta routes deliberately remain on the legacy resolver until 4B2" is false at HEAD: the exhaustive receiver census over `src/` shows zero `PhysicsEngine.Resolve`/`.ResolvePlacement` call sites, and every production placement writer reaches canonical `PhysicsEngine.SetPosition` only through `RuntimeSetPositionState` (three call sites total). C5a deleted `Resolve`, `ResolvePlacement`, and their `HasCellSurface` helper outright — the resolver-shaped entry points this row described no longer exist, so the condition is retired structurally, not just narrowed. The narrower survivors (#276 settle-cell discard, AD-61 force-seed, AD-62 non-commit outcomes) are separately filed rows and are unaffected. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`; `src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs`; `src/AcDream.Runtime/Physics/RuntimePlacementProjectionChannel.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (deletion); `docs/research/2026-08-05-c5a-contract.md` | — | — | `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 | diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 56202593..0b835795 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -932,17 +932,21 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool visible = _spatial.IsLiveEntityProjectionResident(key); record.IsSpatiallyVisible = visible; RefreshPresentation(record); - uint committedFullCell = - (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu - ? spatialCellOrLandblockId - : record.FullCellId; - uint committedLandblock = spatialCellOrLandblockId == 0 - ? 0u - : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; - if (!_entityObjects.CommitRebucket( + // D1 (C5b architecture review): the committed-value derivation that + // used to be inline here — the landblock-shaped-id preserve branch + // and the canonical-landblock mask — moved VERBATIM into + // RuntimeEntityObjectLifetime.CommitWireCellRebucket so the + // no-window host can commit the same value from its own route + // instead of having no post-merge cell writer at all. Behaviour is + // unchanged: `record.FullCellId` is a proxy for + // `record.Canonical.FullCellId` (see this class's own property), + // which is the record the callee reads, and the commit itself is + // still CommitRebucket. AP-146/#320's "LiveEntityRuntime.cs:935-938 + // preserves the prior canonical cell" citation now resolves to that + // method. + if (!_entityObjects.CommitWireCellRebucket( record.Canonical, - committedFullCell, - committedLandblock)) + spatialCellOrLandblockId)) { ThrowAfterCommittedProjectionChange( serverGuid, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 703c4b89..bb0c195d 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1943,15 +1943,28 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // The snapshot's Position field itself IS still refreshed; only the // derived FullCellId write is withheld. // - // Two wire-cell writers deliberately SURVIVE this change, downstream - // of the merge and outside the classification window: the OnPosition - // prologue rebucket (LiveEntityNetworkUpdateController -> - // LiveEntityRuntime.RebucketLiveEntity -> CommitRebucket, which is - // also the local player's own cell-freshness path, AP-146/#320) and - // the post-routing wire-cell adopt for non-placing arms - // (TryAdoptWireCellAfterRouting, filed at AP-135). Neither is gated - // here: gating the prologue rebucket would freeze the local player's + // Wire-cell writers deliberately SURVIVE this change, downstream of + // the merge and outside the classification window. The canonical one + // is CommitWireCellRebucket, in this class: BOTH inbound routes call + // it after the merge, and it is also the local player's own + // cell-freshness path (AP-146/#320). Neither route gates it here: + // gating the prologue rebucket would freeze the local player's // canonical cell between teleports. + // + // CORRECTED 2026-08-05 at the C5b architecture review (D1). This + // comment used to enumerate the survivors as two App classes — + // LiveEntityNetworkUpdateController -> LiveEntityRuntime and + // TryAdoptWireCellAfterRouting (AP-135) — which was both a layering + // inversion (a Runtime file documenting its own correctness by + // naming classes this assembly cannot reference) and, worse, WRONG: + // it described the graphical host as though it were the only host. + // The no-window route (RuntimeLiveEntitySessionController) had + // neither writer, so it had no post-merge cell writer at all and + // every entity's FullCellId froze at its placement value for the + // session. The graphical route's post-routing adopt still exists and + // is still filed at AP-135; the no-window route has no remote + // contact routing and therefore no analogue of it. AD-60 and AD-64 + // carry the complete channel list. Entities.RefreshSnapshot( canonical, snapshot, @@ -2028,6 +2041,72 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable () => canonical.SpatialAuthorityVersion == spatialVersion); } + /// + /// D1 (C5b architecture review): the CANONICAL half of the wire-cell + /// rebucket (AD-60's W2), expressed once, in Runtime, for every host. + /// + /// + /// Why this exists. C5b made the steady-state merge stop writing + /// residency (RefreshSnapshot(..., refreshPosition: false) in + /// ) and left the graphical + /// OnPosition prologue rebucket + /// (LiveEntityNetworkUpdateController → + /// LiveEntityRuntime.RebucketLiveEntity) as the replacement + /// writer. That writer lives in AcDream.App, so the no-window + /// host had NO post-merge cell writer at all: a headless remote's + /// was written at + /// create/placement and then frozen for the session, and the local + /// player lost one of AP-146's three refresh edges. The derivation was + /// also the reason AD-60 documented itself by naming an App class this + /// assembly cannot reference — a layering inversion. Both are fixed by + /// owning the rule here and letting each host's route call it. + /// + /// + /// + /// The landblock-vs-cell rule (verbatim from the graphical site it + /// was extracted from). + /// is overloaded. A LANDBLOCK-shaped id — low 16 bits 0xFFFF, + /// which is what LocalPlayerProjectionController.Project emits + /// for ordinary per-frame movement — deliberately PRESERVES the exact + /// cell and updates only the canonical landblock; writing the coarser + /// value would destroy a resolved EnvCell. Any other id is an exact + /// cell and is committed as-is. An inbound wire objcell_id is + /// always cell-shaped (ACE never sends 0xFFFF in the low half), + /// so on the accepted-Position route this always commits the exact wire + /// cell — the preserve branch is there for the per-frame local caller, + /// which no-window hosts do not have. 0 is passed through + /// unchanged (cell 0 + landblock 0), the withdrawal shape. + /// + /// + /// + /// This method deliberately carries NO gates. Which packets may reach a + /// wire-cell commit is the caller's decision and differs per host route + /// (the graphical route returns ahead of it for the local force arm, + /// the missile arm, and an active initial-create residence); duplicating + /// those tests here would double-gate one host and silently widen the + /// other. + /// + /// + public bool CommitWireCellRebucket( + RuntimeEntityRecord canonical, + uint spatialCellOrLandblockId, + Action? acknowledgeProjection = null) + { + ArgumentNullException.ThrowIfNull(canonical); + uint committedFullCell = + (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu + ? spatialCellOrLandblockId + : canonical.FullCellId; + uint committedLandblock = spatialCellOrLandblockId == 0 + ? 0u + : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + return CommitRebucket( + canonical, + committedFullCell, + committedLandblock, + acknowledgeProjection); + } + public bool CommitWithdrawal( RuntimeEntityRecord canonical, Action? acknowledgeProjection = null) diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 03eb06ef..f3e4cd35 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -237,9 +237,26 @@ public sealed class RuntimeLiveEntitySessionController out _, out AcceptedPhysicsTimestamps timestamps); if (!known - || !isLocal || disposition is PositionTimestampDisposition.Rejected) { + // Rejected writes nothing anywhere — the same shape the + // graphical authority gate produces by returning false from + // LiveEntityInboundAuthorityGate.TryAcceptPosition, which is + // ahead of every wire-cell writer. + return; + } + + if (!isLocal) + { + // D1 (C5b architecture review): the no-window host's half of + // AD-60's W2. The graphical route commits the accepted wire + // cell for EVERY classification that reaches its generic tail, + // remotes included; this route used to return here, so a + // headless remote's FullCellId was written once at + // create/placement and then frozen for the whole session — + // and RuntimeEntityObjectViews.Snapshot feeds exactly that + // field to every bot's RuntimeEntitySnapshot.CellId. + TryCommitAcceptedWireCell(update); return; } @@ -307,6 +324,20 @@ public sealed class RuntimeLiveEntitySessionController // fallback this disposition always had — it must still // run, exactly as every other disposition's fallback // does below. + // + // D1: ... and so does the wire-cell commit. A force the + // drive HANDLED (Committed/DeferredCell) is + // placement-receipt-authoritative for residency, and a + // Rejected/Contention force leaves the last committed + // cell alone (AD-62's shapes) — both are exactly why + // the graphical route returns ahead of W2 on every + // status except NotApplicable. Ordering matters as well + // as reachability: the drive submits its placement + // BEFORE this point, so its first submit reads the + // pre-commit FullCellId, which is the source landblock + // — AP-138's amended route-2 measurement, matched here + // rather than accidentally improved on. + TryCommitAcceptedWireCell(update); _worldProjection?.ProjectPosition( record, isLocalPlayer: true, @@ -315,6 +346,7 @@ public sealed class RuntimeLiveEntitySessionController } else { + TryCommitAcceptedWireCell(update); _worldProjection?.ProjectPosition( record, isLocalPlayer: true, @@ -324,6 +356,63 @@ public sealed class RuntimeLiveEntitySessionController TryCompletePortal(); } + /// + /// D1 (C5b architecture review): commits the accepted wire cell to + /// canonical residency for a no-window host, under the same + /// reachability rules the graphical OnPosition route applies to + /// AD-60's W2. The committed VALUE is + /// 's — + /// one rule, shared by both hosts, including its landblock-vs-cell + /// branch. + /// + /// + /// Two gates mirror callers the graphical route has and this one does + /// not. The initial-create residence is + /// LiveEntityRuntime.RebucketLiveEntity's own early return + /// (MaterializationResidence is AwaitRuntimePlacement && + /// HasActiveInitialCreateResidence): while the lease is live, + /// Runtime's SetPosition conductor is the sole cell authority. + /// Only the residence half is tested here, because it IS the whole + /// test on this side — the App enum's AwaitRuntimePlacement value + /// exists to mark records that took the residence route, which is every + /// record a projection-backed direct host registers, and a content-less + /// direct host opens no lease at all + /// (). A missile packet is routed by the + /// graphical host through the canonical projectile placement owner and + /// returns before W2; the predicate below is the exact conjunction that + /// route's own null-classification arm uses + /// (LiveEntityNetworkUpdateController.OnPosition, the + /// isMissilePacket ternary), which its D-P1 comment records as + /// equivalent to the classifier's ProjectileAuthoritative + /// operation kind. Committing a wire cell for a projectile here would + /// invent residency a placement route owns. + /// + /// + private void TryCommitAcceptedWireCell( + WorldSession.EntityPositionUpdate update) + { + if (!Entities.Entities.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical) + || Entities.TryGetInitialCreateResidence(canonical, out _) + || IsMissilePacket(canonical, update.Guid)) + { + return; + } + + _ = Entities.CommitWireCellRebucket( + canonical, + update.Position.LandblockId); + } + + private bool IsMissilePacket( + RuntimeEntityRecord canonical, + uint guid) => + guid != _runtime.PlayerIdentity.ServerGuid + && (canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0 + && canonical.Projectile is { } projectile + && ReferenceEquals(canonical.PhysicsBody, projectile.Body); + private void OnVectorUpdated(VectorUpdate.Parsed update) => _ = Entities.TryApplyVector( update, diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 77211094..4fa15037 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1936,6 +1936,125 @@ public sealed class HeadlessSessionHostTests } } + /// + /// D1 (C5b architecture review), the local-player FORCE arm — both + /// directions of the reachability rule, driven through the real + /// sink. + /// + /// + /// The graphical OnPosition route returns ahead of AD-60's W2 + /// for every force status EXCEPT NotApplicable, and falls + /// through to it on that one. So: a force the drive HANDLED + /// (Committed here) is placement-receipt-authoritative and this + /// route must leave residency at the cell the placement RESOLVED, not + /// re-stamp the wire cell over it; a force the drive did NOT handle + /// (no drive at all — the login-window / route-1 shape) must still + /// refresh the cell, because nothing else will. + /// + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LocalForcePosition_CommitsTheWireCellOnlyWhenTheDriveDeclined( + bool driveHandlesIt) + { + 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 = 0x50000004u; + 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); + 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(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + projection.ProjectSpawn(record, isLocalPlayer: true); + PlayerMovementController controller = + Assert.IsType( + runtime.MovementOwner.Controller); + controller.SeedPlacementForTest( + new Vector3(48f, 49f, 50f), + 0xA9B40001u, + new Vector3(48f, 49f, 50f)); + // The residence must be closed before the ordinary post-residence + // rules apply at all (the conductor is the sole cell authority + // while it is open — asserted separately in the Runtime suite). + Assert.False(runtime.EntityObjects.TryGetInitialCreateResidence( + record, + out _)); + + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000)); + var entities = new RuntimeLiveEntitySessionController( + runtime, + session, + log: null, + projection, + driveHandlesIt ? CreateAcceptedPositionDrive(runtime) : null); + LiveEntitySessionSink sink = entities.CreateSink(); + + // A wire cell that is NOT the cell a placement at (72,73) resolves + // to, so "committed the wire cell" and "kept the placement's own + // resolved cell" are distinguishable values rather than the same + // number arrived at two ways. + const uint wireCell = 0xA9B40002u; + sink.PositionUpdated(new WorldSession.EntityPositionUpdate( + player, + new CreateObject.ServerPosition( + wireCell, 72f, 73f, 50f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 1)); + + if (driveHandlesIt) + { + // The placement committed and resolved its OWN cell — measured + // 0xA9B4001C, the outdoor landcell that actually contains + // (72, 73) in this flat landblock, which is neither the wire + // cell nor the spawn cell. This route added nothing on top of + // it. + Assert.Equal(new Vector3(72f, 73f, 50.005f), controller.Position); + Assert.Equal(0xA9B4001Cu, record.FullCellId); + Assert.NotEqual(wireCell, record.FullCellId); + } + else + { + Assert.Equal(wireCell, record.FullCellId); + Assert.Equal(0xA9B4FFFFu, record.CanonicalLandblockId); + } + } + private static void AddFlatLandblock(PhysicsEngine engine) { var heights = new byte[81]; diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs index a8ea2aef..41f4b81b 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionIsolationTests.cs @@ -351,6 +351,65 @@ public sealed class HeadlessSessionIsolationTests host.Runtime.CaptureOwnership().IsConverged)); } + /// + /// D1 (C5b architecture review), end to end through a real + /// : a bot's view of a remote must + /// track the server, not freeze at the cell the remote was created in. + /// + /// + /// C5b made the steady-state accepted-Position merge stop writing + /// residency and left the replacement writers (AD-60's W2/W3) in + /// AcDream.App. This host has neither, so before the fix + /// RuntimeEntityRecord.FullCellId — the field + /// RuntimeEntityObjectViews.Snapshot projects as + /// RuntimeEntitySnapshot.CellId, i.e. every bot's world view — + /// was written once at create and then never again for the life of the + /// session, however far the server said the remote walked. + /// + /// + [Fact] + public void RemoteSteadyStatePositionAdvancesTheBotVisibleCell() + { + const uint playerGuid = 0x50000001u; + const uint remoteGuid = 0x70000031u; + var operations = new FixtureOperations(playerGuid); + using HeadlessSessionHost host = CreateHost(0, operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + WorldSession session = operations.ActiveSession(host.SessionId); + session.GameActionCapture = _ => { }; + + SpawnInto(session, remoteGuid, 1f); + Assert.True(host.Runtime.Entities.TryGet( + remoteGuid, + out RuntimeEntitySnapshot created)); + Assert.Equal(0x01010001u, created.CellId); + + // An ordinary broadcast Position: no teleport channel, no force + // channel — the exact packet shape C5b stopped committing. + const uint movedCell = 0x01010025u; + EventDelegate>( + session, + nameof(session.PositionUpdated))( + new WorldSession.EntityPositionUpdate( + remoteGuid, + Position(2f) with { LandblockId = movedCell }, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0)); + + Assert.True(host.Runtime.Entities.TryGet( + remoteGuid, + out RuntimeEntitySnapshot moved)); + Assert.Equal(movedCell, moved.CellId); + Assert.Equal(movedCell, moved.Position!.Value.ObjCellId); + } + private static HeadlessSessionHost CreateHost( int index, FixtureOperations operations, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 656aa0d3..96a0eb29 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -493,6 +493,290 @@ public sealed class RuntimeLiveEntitySessionControllerTests drive.DetachRoute(route); } + /// + /// D1 (C5b architecture review) — THE discriminating test for the + /// no-window host's missing post-merge cell writer. C5b made the + /// steady-state merge withhold the wire cell and left the replacement + /// writers (AD-60's W2/W3) in AcDream.App, which this route does + /// not have and cannot reach. Before the fix a headless remote's + /// FullCellId was written at create/placement and then frozen for + /// the whole session, no matter how far the server said it walked, and + /// RuntimeEntityObjectViews.Snapshot feeds exactly that field to + /// every bot's RuntimeEntitySnapshot.CellId. + /// + [Fact] + public void AcceptedRemotePosition_AdvancesCanonicalResidencyInANoWindowHost() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + SpawnAt(0x70000020u, incarnation: 1, 0x01010001u); + + sink.Spawned(spawn); + DrainFirstEntry(runtime, drive); + + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord remote)); + uint placedCell = remote.FullCellId; + Assert.NotEqual(0u, placedCell); + + // A steady-state Position that crosses into another cell of the SAME + // landblock — no teleport channel, no force channel, so this is the + // ordinary accepted-Position merge, the exact packet shape C5b + // stopped writing residency for. + const uint movedCell = 0x01010013u; + Assert.NotEqual(movedCell, placedCell); + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + movedCell, + positionX: 40f, + positionSequence: 2)); + + Assert.Equal(movedCell, remote.FullCellId); + Assert.Equal(0x0101FFFFu, remote.CanonicalLandblockId); + // The bot-visible projection, which is the observable this defect + // actually broke. + Assert.True(runtime.Entities.TryGet( + spawn.Guid, + out RuntimeEntitySnapshot view)); + Assert.Equal(movedCell, view.CellId); + } + + /// + /// D1's local-player half. AP-146 enumerates three edges that refresh + /// the local player's canonical cell; C5b moved the inbound-Position + /// one out of the merge and into an App-only writer, so a no-window + /// host lost it entirely. The consequence is not cosmetic: + /// RuntimeSetPositionState.IsAffectedCollisionResident reads + /// FullCellId to decide which bodies a landblock retirement + /// parks, so a bot that runs A->B without teleporting would have + /// retired A while parking a body that is physically in B. + /// + [Fact] + public void AcceptedLocalPlayerPosition_AdvancesCanonicalResidencyInANoWindowHost() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + const uint playerGuid = 0x50000020u; + runtime.PlayerIdentity.ServerGuid = playerGuid; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + + sink.Spawned(SpawnAt(playerGuid, incarnation: 1, 0x01010001u)); + DrainFirstEntry(runtime, drive); + + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + playerGuid, + out RuntimeEntityRecord player)); + uint placedCell = player.FullCellId; + Assert.NotEqual(0u, placedCell); + + const uint movedCell = 0x01010021u; + Assert.NotEqual(movedCell, placedCell); + sink.PositionUpdated(PositionUpdate( + playerGuid, + movedCell, + positionX: 55f, + positionSequence: 2)); + + Assert.Equal(movedCell, player.FullCellId); + Assert.Equal(0x0101FFFFu, player.CanonicalLandblockId); + } + + /// + /// D1's negative half, stated as three separate rules rather than one + /// aggregate assertion. + /// + /// + /// (a) A Rejected disposition writes NOTHING — the shape the + /// graphical route gets for free by returning false from + /// LiveEntityInboundAuthorityGate.TryAcceptPosition ahead of + /// every wire-cell writer. (b) An active initial-create residence + /// suppresses the commit, mirroring + /// LiveEntityRuntime.RebucketLiveEntity's own early return: + /// while the lease is live, Runtime's SetPosition conductor is the sole + /// cell authority and a wire cell must not pre-empt it. (c) A + /// LANDBLOCK-shaped id preserves the exact cell instead of coarsening + /// it — the rule fact 4 of this fix's brief warned about, asserted + /// directly against the shared derivation. + /// + /// + [Fact] + public void WireCellCommit_HonoursRejection_Residence_AndTheLandblockPreserveRule() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + CommitLandblockCollision(runtime, 0x01010000u); + RuntimeFirstEntryDriveController drive = CreateDrive(runtime); + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + var controller = new RuntimeLiveEntitySessionController( + runtime, + session, + worldProjection: new FixtureWorldProjection()); + LiveEntitySessionSink sink = controller.CreateSink(); + WorldSession.EntitySpawn spawn = + SpawnAt(0x70000021u, incarnation: 1, 0x01010001u); + + // (b) The residence is open between Spawned and the drive's drain. + sink.Spawned(spawn); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + spawn.Guid, + out RuntimeEntityRecord remote)); + Assert.True(runtime.EntityObjects.TryGetInitialCreateResidence( + remote, + out _)); + uint duringResidence = remote.FullCellId; + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + 0x01010031u, + positionX: 12f, + positionSequence: 2)); + Assert.Equal(duringResidence, remote.FullCellId); + + DrainFirstEntry(runtime, drive); + uint placedCell = remote.FullCellId; + Assert.NotEqual(0u, placedCell); + + // (a) A stale position sequence is Rejected by the timestamp gate. + sink.PositionUpdated(PositionUpdate( + spawn.Guid, + 0x01010041u, + positionX: 13f, + positionSequence: 1)); + Assert.Equal(placedCell, remote.FullCellId); + + // (c) The landblock-shaped id preserves the exact cell. + Assert.True(runtime.EntityObjects.CommitWireCellRebucket( + remote, + 0x0202FFFFu)); + Assert.Equal(placedCell, remote.FullCellId); + Assert.Equal(0x0202FFFFu, remote.CanonicalLandblockId); + } + + /// + /// D1's missile gate. The graphical route sends a BOUND projectile's + /// accepted Position to the canonical projectile placement owner and + /// returns before AD-60's W2, so no wire cell is committed for it; the + /// no-window route must not invent one. An UNBOUND Missile-flagged + /// record takes the ordinary remote tail in both hosts — the same + /// distinction + /// pins on the classifier, asserted here on the residency write. + /// + [Fact] + public void BoundProjectilePosition_CommitsNoWireCell_UnboundMissileDoes() + { + using StartedRuntime started = StartRuntime(); + GameRuntime runtime = started.Runtime; + using var session = new WorldSession( + new IPEndPoint(IPAddress.Loopback, 9000), + new FixtureTransport()); + // Content-less direct host: legacy registration, no residence lease, + // so the packet reaches the wire-cell decision with nothing else in + // the way. + var controller = new RuntimeLiveEntitySessionController( + runtime, + session); + LiveEntitySessionSink sink = controller.CreateSink(); + + const uint boundGuid = 0x70000041u; + const uint unboundGuid = 0x70000042u; + sink.Spawned(SpawnAt(boundGuid, incarnation: 1, 0x01010001u)); + sink.Spawned(SpawnAt(unboundGuid, incarnation: 1, 0x01010001u)); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + boundGuid, + out RuntimeEntityRecord bound)); + Assert.True(runtime.EntityObjects.Entities.TryGetActive( + unboundGuid, + out RuntimeEntityRecord unbound)); + + foreach (RuntimeEntityRecord missile in new[] { bound, unbound }) + { + runtime.EntityObjects.Entities.SetFinalPhysicsState( + missile, + missile.FinalPhysicsState | PhysicsStateFlags.Missile); + } + + var body = new PhysicsBody + { + Position = new System.Numerics.Vector3(10f, 10f, 5f), + Orientation = System.Numerics.Quaternion.Identity, + LastUpdateTime = 1d, + State = bound.FinalPhysicsState, + TransientState = TransientStateFlags.Active, + }; + body.SnapToCell(0x01010001u, body.Position, body.Position); + runtime.EntityObjects.Entities.SetPhysicsBody(bound, body); + runtime.EntityObjects.Physics.BindProjectile( + bound, + body, + new ProjectileCollisionSphere( + System.Numerics.Vector3.Zero, + 0.1f, + 1f)); + Assert.NotNull(bound.Projectile); + Assert.Null(unbound.Projectile); + + const uint movedCell = 0x01010051u; + sink.PositionUpdated(PositionUpdate( + boundGuid, + movedCell, + positionX: 30f, + positionSequence: 2)); + sink.PositionUpdated(PositionUpdate( + unboundGuid, + movedCell, + positionX: 30f, + positionSequence: 2)); + + Assert.Equal(0x01010001u, bound.FullCellId); + Assert.Equal(movedCell, unbound.FullCellId); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + uint cellId, + float positionX, + ushort positionSequence) => + new( + guid, + new CreateObject.ServerPosition( + cellId, + positionX, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: 0, + ForcePositionSequence: 0); + /// /// C3c: initial-residence admission requires a live session generation /// (RuntimeInitialCreateResidenceState.CanAcceptCreate), so these direct