diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5e64dfa6..ea5c425b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1698,6 +1698,12 @@ which uses the production classifier as the oracle. Wiring the continuation executor into the steady-state path is a separate structural decision that no longer has any behavioural motivation behind it. +**Successor filed 2026-08-05 at the C5b review (finding S1): #322.** This +closure originally left "the remaining structural item is tracked below" with +no ID, and the production comment on +`InboundPhysicsStateController.TryApplyPosition` pointed at `docs/ISSUES.md` +for a follow-up that did not exist. Both now cite #322. + --- ## #274 — Restricted/barred-house entry needs a connected retail comparison @@ -13521,3 +13527,110 @@ under repetition, it is a real race and should be escalated out of LOW. Do not add a retry, a `Skip`, or a delay to make it green — this campaign's standing rule is no workarounds without explicit approval, and a masked race is strictly worse than a red test. + +--- + +## #322 — Two callers compute the same two pre-placement flags from the same two inputs + +**Status:** OPEN +**Severity:** LOW (internal refactor debt; NOT a retail divergence) +**Filed:** 2026-08-05 (C5b review, finding S1 — the successor #275 closed +without filing) +**Component:** Runtime / inbound Position + +**Description.** Since C5b (`735f0a72`) both accepted-Position callers derive +retail's two pre-placement writes — `installPlacementFrame` and `clearParent` — +from the identical pair `(disposition, hasAnimations)`, in two separate places: + +- `InboundPhysicsStateController.TryApplyPosition` computes them inline, + pre-merge, from the retained snapshot `old`. +- `RuntimeInitialCreateContinuationExecutor.ApplyPositionAction` reads them off + `RuntimeAuthoritativePositionRouteClassifier`'s + `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting`, built through + `RuntimeAcceptedPositionRouteRequests.Build`. + +The two are pinned EQUAL by test rather than by a shared code path +(`InboundPhysicsStateControllerTests.MergedPrePlacementFieldsMatchTheClassifiedRouteFlags`, +whose oracle is the production classifier fed through the production `Build` +since the C5b review's L3 fix). That is deliberate: it keeps each computation +separately sabotage-verifiable. It is still two copies of one rule. + +**Why this is not #275.** #275 was the BEHAVIOURAL unification and is closed: +the merge no longer passes unconditional `true/true`, and it no longer derives +`FullCellId` from bare wire acceptance. What remains is structural only. There +is no behavioural motivation left behind it, which is precisely why it needs +its own ID rather than an open-ended "eventual cutover" pointer. + +**Acceptance (either is fine, pick at the time):** (a) wire the continuation +executor into the steady-state path so there is one caller, or (b) extract the +two-flag derivation into one function both callers call — but if (b), the pin +test must be re-argued, since a shared path makes the current +merge-vs-classifier sabotage discrimination vacuous. + +**Do not** widen `TryApplyPosition`'s signature to take a full +`RuntimeAuthoritativePositionRoute` as a shortcut: the whole point of C5b's +finding is that these two flags need no route, no `playerDistance` and no +`CommittedCellId`, because retail decides both ahead of `MoveOrTeleport` +(`SmartBox::HandleReceivedPosition` @0x00453FD0 — Gate A @0x0045400C returns +@0x0045409D before `unset_parent` @0x00454129 and before the `HasAnims` +`SetPlacementFrame` gate @0x00454137). + +--- + +## #323 — A far-snap store can silently stale a pending initial-create completion receipt + +**Status:** OPEN +**Severity:** LOW (narrow, self-healing within one broadcast interval; no +observed live symptom) +**Filed:** 2026-08-05 (C5b review, finding D2 — found while establishing that +C5b did NOT weaken the guard; this gap predates C5b) +**Component:** App / placement projection + +**Description.** `LiveEntityRuntime.TryApplyInitialCreateCompletionPresentation` +declines a stale `ExecutorCompleted` receipt on two terms: +`record.FullCellId != token.ExactCellId` and +`record.Canonical.PlacementCommitVersion != token.PlacementCommitVersion`. +Together those cover every owner that can move the receipt's facts — the +receipt carries the canonical body's pose and cell at publish +(`RuntimeSetPositionState.PublishExecutorCompletion`), and only a Runtime +SetPosition commit/withdrawal (every one of which calls +`AdvancePlacementCommit`; the only caller family is `RuntimeSetPositionState`) +or a rebucket can move them. + +**Except one.** `RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose` +writes `body.Position` and `body.Orientation` directly on the far-snap +`Refused`/`Contention`/`RejectedPreparation` arm (AP-138 item (1)'s +"store, because the resolve never ran"). It bumps no placement commit and +moves no cell. If an `ExecutorCompleted` receipt for that entity is still +sitting behind an unacknowledged receipt on the shared placement FIFO when +that store lands, the receipt drains with a pose that is now older than the +body's, and `entity.SetPosition(projection.WorldPosition)` snaps the render +entity back. + +**Why the FIFO can be non-empty at that moment.** `PublishExecutorCompletion` +dispatches synchronously, but `RuntimePlacementProjectionSubscription.OnPlacement` +applies a receipt only when it is the FIFO head, and +`RuntimePlacementPresentationSink.TryApply` deliberately refuses (leaves at the +head) any `Place`/`Withdraw` for an entity still holding an initial-create +residence, for the drive controller's per-frame pump to consume. So one +entity's conductor-owned receipt can hold another entity's `ExecutorCompleted` +behind it across network packets. + +**Not established:** whether the combination is actually reachable in play — it +needs a ≥96 m far-snap classification for an entity whose initial-create +completion is still queued, and the far arm's refusal reasons are themselves +narrow. Reported rather than fixed for exactly that reason. + +**Do NOT fix it by adding `PositionAuthorityVersion` to the guard.** That was +the C5b reviewer's proposed shape and it is wrong: the merge bumps that version +on every accepted Position including ones that move nothing, so the guard would +decline receipts whose facts are still true, on the entity's FIRST +world-visible moment — skipping the pose write and +`RebucketLiveEntityPresentationOnly` while `TryPublishPlace` still publishes. +The correct shape, if this is ever confirmed reachable, is to make the store +arm advertise itself (a body-pose authority version, or routing the store +through a commit-versioned seam) so the receipt can see it. + +**Superseded text, for the record.** The comment at the guard used to say only +"A newer move superseded this receipt's facts after the drain." It now carries +the full argument and cites this issue. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bfe482b0..1d2f7603 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -152,14 +152,14 @@ 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. | `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 — 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-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 | --- -## 3. Documented approximation (AP) — 100 active rows (AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 101 active rows (AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -172,6 +172,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | 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-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 | @@ -290,7 +291,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) | | AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and a non-Position rebucket writer (the projection materializer `DatLiveEntityProjectionMaterializer` — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer `EquippedChildRenderController.TickChild`, to a presentation-only bucket move that no longer touches `record.FullCellId`) can rebucket that field to a third landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | | AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten 2026-08-04 at the dual Opus review; REWRITTEN AGAIN 2026-08-04 (C4 route 4b-3) — the cell-less enqueue-vs-place delta this row existed to record is RETIRED, not merely re-scoped: the teleport arm now ports retail's `teleport_hook` verbatim and places unconditionally through the canonical Runtime placement owner, exactly like retail's `this_1->cell == 0` @0x00516386 branch. What survives is the two acdream-only divergences retail has no state for at all.** acdream can classify a remote's accepted Position into two states retail cannot reach, sharing ONE stated handler (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`, AP-87's shared `ApplyInterpolate` catch-up) instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; **route 4b-3 adds two more null-producing reasons** — the merge observed no PRIOR canonical record for this entity, so the classifier has no honest pre-merge cell to feed the teleport predicate and declines rather than fabricate one (D1); and the dormant initial-residence enqueue path (`RuntimeEntityObjectLifetime.TryApplyPosition`'s `EnqueueDormant` return, reached BEFORE the method's own `PreMergeCommittedCellId` write), whose timestamps therefore always carry `PreMergeCommittedCellId: null` too — fix round 2026-08-04 (R8), unverified from static reading whether `OnPosition` reaches `ClassifyRemoteAcceptedPosition` for an enqueued packet at all, stated honestly rather than guessed; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither. **D1 — the visibility arm is deleted, not merely narrowed.** Before 4b-3, `LiveEntityRuntime.TryApplyPosition` computed `projectionRequiresTeleportHook` as `pre-merge FullCellId == 0 OR !IsSpatiallyProjected OR !IsSpatiallyVisible` — a presentation predicate with NO retail analogue, since retail's `MoveOrTeleport` never reads visibility. That whole computation, the lifetime parameter, and the headless `false` argument are deleted; a not-visible remote's Position now classifies purely by distance/contact like any other, and visibility is presentation-only. **D2 — the wire-airborne leftover shape.** After the teleport/cell-less classification moves onto its own arm, a packet whose classification is null/`RejectedAuthority`/`RejectedData` AND whose wire contact bit is clear takes retail's return-0 shape: AP-135's two bookkeeping writes only (server-cell adopt, `LastServerPos`/`LastServerPosTime`), no body/queue/render write, no leash arm. This deletes the legacy player-arm fallback's entity-revert quirk (`entity.SetPosition(rmState.Body.Position)`) and unifies player and NPC remotes on one behaviour. **R3 (retained from the prior rewrite) — `RejectedData` is APPLIED anyway** when grounded. It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so both the far snap and the teleport arm are graphical-host-only paths | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); `src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs` (`OwnsTeleportPlacement`, the retired predicate's replacement); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s teleport check and default arm, the D2 wire-airborne shape); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body for the two survivors, so a remote keeps tracking the server through the login window and through a rejected packet | The two survivors are unaffected by 4b-3: a leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range. If AP-87's 4 m backstop were ever weakened, this arm would become a silent-freeze path | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport — now ported, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | -| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writer (the projection materializer — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer, to a presentation-only move that no longer touches `record.FullCellId`) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | +| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers committed the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm named the destination — measured 2026-08-04 at round 3. **AMENDED 2026-08-05 at the C5b architecture review: the route-2 half of that measurement is now STALE and the two callers no longer agree.** C5b made the merge withhold the wire cell (AD-60), and route 2 submits from `TryExecuteAcceptedLocalPosition` BEFORE the `OnPosition` prologue rebucket (W2) it returns ahead of — so on a route-2 FIRST submit `PlacementTouchesPrefix`'s `CurrentCellId` arm now names the SOURCE landblock the local player is leaving, not the destination. The graphical REMOTE half is unchanged: its prologue rebucket still runs ahead of the far-snap submit. The consequence is confined to which prefix the quiescence pre-flight matches, which this row's own part (2) already established cannot be the correctness mechanism (`SubmitPreparedPlacementCore`'s restorable parks are); it widens rather than narrows the set of prefixes a local force can be parked against. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writer (the projection materializer — C4 route 4b-3 deleted the second shipped writer, `RemoteTeleportController`'s rollback, and C4 route 7 D4 demoted the third, the equipped-child renderer, to a presentation-only move that no longer touches `record.FullCellId`) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | ## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs index 520df2f3..0a432b87 100644 --- a/src/AcDream.App/Physics/ProjectileController.cs +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -503,38 +503,62 @@ internal sealed class ProjectileController /// before this is called. /// /// - /// B1/B2 fix (round-2 review — reverting round 1's own R6 "fix", - /// which the retail reviewer retracted as factually wrong). + /// Which cell this projects — REWRITTEN 2026-08-05 (C5b review, D4). + /// The round-2 B1/B2 text this replaces was falsified by C5b and its + /// retail argument now points the other way; do not restore it. /// ParentCellId is read from record's (the local resolved /// by TryGetCurrent below, equal to /// once the currency check passes) - /// FullCellId — the WIRE cell — never the body's own - /// CellPosition.ObjCellId. On a committed outcome the two agree - /// (CommitCanonical writes both together), so the choice is a - /// no-op there. On a STORED outcome - /// (Refused/Contention/RejectedPreparation), - /// StoreAcceptedDestinationPose composes body.Position - /// from accepted.PositionX + worldOffset(accepted.LandblockId) — - /// the WIRE cell's world frame — while record.FullCellId is that - /// SAME wire cell (stamped by the merge's - /// RefreshDerivedState/SetFullCell, before classification - /// ever runs). The body's OWN CellPosition.ObjCellId, in - /// contrast, is never touched by the store fallback and is therefore - /// the SOURCE cell the body left — reading it here would pair the new - /// destination position with the cell the body is no longer in. Three - /// independent checks confirm record.FullCellId is the correct - /// source: (1) the sibling remote arm's - /// TryApplyGenericRemoteRenderPose pairs the wire world position - /// with the wire cell the same way; (2) Runtime's own - /// SyncProjectilePresentation, forty lines away in the SAME - /// packet, publishes the shadow row at record.FullCellId — so - /// reading the body's cell here would disagree with the shadow for the - /// SAME body in the SAME call; (3) retail's store_position - /// @0x00515CE2 writes the object's whole Position INCLUDING - /// objcell_id, so after a retail store the object's cell IS the - /// destination (the wire cell) — never the stale one. The body's own - /// stale cell after a store is the acdream-side residual (AP-138, - /// shared with the remote arms), not the truth to project. + /// FullCellId — since C5b (#275) that is the last COMMITTED cell, + /// never the wire cell. The steady-state merge withholds the derived + /// FullCellId write entirely + /// (RuntimeEntityObjectLifetime.TryApplyPosition's + /// RefreshSnapshot(..., refreshPosition: false), AD-60), and the + /// missile arm returns from OnPosition before the prologue + /// rebucket (W2) ever runs, so nothing between admission and this call + /// stamps the wire cell for a projectile. + /// + /// + /// + /// The choice of source is now a no-op on every outcome. On a + /// COMMITTED outcome CommitCanonical writes + /// record.FullCellId and body.CellPosition.ObjCellId + /// together. On a STORED outcome + /// (Refused/Contention/RejectedPreparation) + /// StoreAcceptedDestinationPose writes body.Position and + /// body.Orientation and NOTHING else — it does not move the + /// body's CellPosition.ObjCellId, and post-C5b it does not move + /// the record's FullCellId either. Both therefore hold the same + /// last-committed (SOURCE) cell, so reading either field yields the + /// identical value. That is what + /// MissileFarRefused_…ParentCellIdAgreesWithCommittedCell pins: + /// an identity between the record and the body, not a wire-cell + /// constant. Runtime's own SyncProjectilePresentation, forty + /// lines away in the SAME packet, publishes the shadow row at + /// record.FullCellId, so this keeps presentation and shadow in + /// agreement for the same body in the same call. + /// + /// + /// + /// The pairing this leaves is genuinely torn, and is named rather + /// than papered over. After a stored outcome the render entity gets + /// the DESTINATION world position (the store composed it from + /// accepted.PositionX + worldOffset(accepted.LandblockId), the + /// wire cell's world frame) paired with the SOURCE cell. Retail cannot + /// produce that state twice over: it has no refusable placement at all + /// (AD-62), and its store_position @0x00515CE2 writes the whole + /// Position INCLUDING objcell_id before + /// GotoLostCell @0x00515CF2, so a retail store ends at the + /// destination cell AND the destination pose. The divergence is + /// acdream's store-writes-pose-but-not-cell residual — AP-138 item (1)'s + /// "at the destination, with no resolved cell", shared with both remote + /// arms — and its retirement is issue #309 (make the failure path open + /// retail's lost-cell registration), NOT reading a different field here. + /// Projecting the wire cell instead would invent a residency the + /// placement explicitly declined, which is the exact AP-1-shaped write + /// C5b closed. Reachability today is deterministic-test-only: ACE never + /// sends UpdatePosition for a missile (AP-141's evidence, + /// WorldObject_Tick.cs:333-334/:265). /// /// /// diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 08ffe9f8..56202593 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1221,6 +1221,52 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource != token.PlacementCommitVersion) { // A newer move superseded this receipt's facts after the drain. + // + // WHY THESE TWO TERMS AND NOT `PositionAuthorityVersion` (C5b + // review, finding D2 — examined and deliberately left alone; + // the sibling guard in + // TryGetRuntimePlacementProjectionRecord(requirePlacementVersions: + // true) DOES check it, and the asymmetry is intentional). + // + // This receipt's facts are the CANONICAL BODY's pose and cell at + // publish time (RuntimeSetPositionState.PublishExecutorCompletion + // builds the snapshot from `record.PhysicsBody` and + // `record.FullCellId`). There are exactly two owners that can move + // either: a Runtime SetPosition commit/withdrawal, every one of + // which calls `AdvancePlacementCommit` — the ONLY caller family is + // RuntimeSetPositionState itself — and a rebucket, which moves + // `FullCellId`. Both are covered above. + // + // An intervening accepted steady-state Position is NOT one of + // them, and specifically was not turned into one by C5b (#275). + // C5b made the merge withhold the `FullCellId` write, but the + // merge never wrote the canonical body either: it refreshes the + // snapshot and advances `PositionAuthorityVersion`, and the + // App-side generic tail writes the RENDER entity, not the body. + // The wire-cell half is still covered here because the OnPosition + // prologue rebucket (W2) commits it in the same call, so a + // cell-changing Position still trips the first term; the paths + // that return before W2 (the local force arm, the missile arm) + // leave the record exactly where the last commit put it, which is + // this receipt's own cell — correctly NOT a supersession. + // + // Adding `PositionAuthorityVersion` would therefore decline + // receipts whose facts are still true, on the entity's FIRST + // world-visible moment: the pose write and + // RebucketLiveEntityPresentationOnly below would be skipped while + // TryPublishPlace still publishes, so a packet that returned + // before the generic tail's render write would leave the sidecar + // visible at its materialized pose in a possibly-wrong bucket. + // That is a behaviour change in the wrong direction, not a + // restoration. + // + // The ONE supersession neither term covers is + // RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose: + // the far-snap Refused/Contention arm writes `body.Position` and + // `body.Orientation` with no placement commit and no cell move, so + // it can stale this receipt's pose silently. That is pre-existing + // (it predates C5b, which changed nothing about that arm) and is + // filed as docs/ISSUES.md #323 rather than papered over here. return true; } diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index 9b389132..9db99b88 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -607,11 +607,14 @@ public sealed class InboundPhysicsStateController /// contact still comes solely from the retained wire packet's own /// IsGrounded bit on both. That remaining structural difference /// (two callers computing the same two flags from the same two inputs - /// rather than sharing one code path) is internal refactor debt tracked - /// for the eventual cutover unification - it is NOT a retail divergence - /// and does not belong in - /// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md - /// for the tracked follow-up. + /// rather than sharing one code path) is internal refactor debt - it is + /// NOT a retail divergence and does not belong in + /// docs/architecture/retail-divergence-register.md. It is tracked as + /// docs/ISSUES.md issue #322, filed 2026-08-05 at the C5b review + /// (finding S1): #275 is CLOSED and this comment's former "tracked for + /// the eventual cutover unification / see docs/ISSUES.md" wording pointed + /// at nothing once it was. #322 also records why widening this method's + /// signature to take a whole route would be the wrong unification. /// public bool TryApplyPosition( WorldSession.EntityPositionUpdate update, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs index 8c233421..cb21a238 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs @@ -80,6 +80,97 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests private static bool IsPlayer(uint guid) => guid == PlayerGuid; + // ── Scenario 0: C5b's untested load-bearing claim ─────────────────── + + /// + /// C5b (#275) review, findings L1/L2. C5b made the steady-state merge stop + /// stamping the wire cell and justified it with a claim it never tested: + /// "production installs the bucket at W2 in the same call (verified: no + /// return between the recovery call and W2 is conditioned on + /// IsSpatiallyProjected or FullCellId)". The commit also asserted "no + /// fixture covers pickup at that layer" — this file drives the real + /// at ~26 call + /// sites, and every W2 assertion the commit added hand-calls + /// RebucketLiveEntity/CommitRebucket itself, so nothing + /// drove the sequence end-to-end. This does. + /// + /// The scenario is the leave-world/re-entry edge the production + /// comment at LiveEntityNetworkUpdateController.cs:2073-2077 names: + /// the record keeps WorldEntity as its logical/render-resource + /// owner while IsSpatiallyProjected is false, and "a fresh retail + /// Position is the re-entry edge; testing only for the retained object + /// reference leaves dropped inventory permanently invisible after + /// InventoryPutObjectIn3D". The materializer DECLINES (the post-C5b + /// self-projection state — DatLiveEntityProjectionMaterializer.cs:767 + /// now sees a canonical cell the merge did not advance), so the only + /// things that can restore the bucket and the cell are the two wire-cell + /// channels AD-60 says deliberately survive C5b. + /// + /// Measured at the review, and NOT what C5b's commit message + /// assumed: W2 and W3 are REDUNDANT here. Sabotaging W2 alone (both + /// "adopt the committed cell instead of the wire cell" and "skip the + /// rebucket entirely") leaves this test — and every other test in this + /// file — GREEN, because the post-routing wire-cell adopt + /// (TryAdoptWireCellAfterRouting, W3/AP-135) writes + /// RemoteMotion.CellId, which reads through to canonical + /// FullCellId via CommitCanonicalCell, whose graphical + /// CellCommitted recovery re-installs the bucket. Only removing + /// BOTH surviving channels turns this red — and it is then the ONLY red + /// in the file. So what this test pins is AD-60's actual surviving claim + /// ("two wire-cell writers deliberately remain downstream of the merge"), + /// not W2 in isolation. Naming it after W2 would have been a third + /// contract asserting a mechanism that is not the one doing the work. + /// + /// + /// Both guid classes per this file's own discipline. + /// + [Theory] + [InlineData(PlayerGuid)] + [InlineData(CreatureGuid)] + public void WithdrawnProjection_AcceptedPositionRestoresBucketAndWireCell( + uint guid) + { + using var fixture = new Fixture(guid, decliningMaterializer: true); + Assert.True(fixture.Runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.True(record.IsSpatiallyProjected); + Assert.Equal(SourceCell, record.FullCellId); + + // The withdrawal: render bucket gone, logical record + WorldEntity + // retained. This is what makes RequiresSpatialProjectionRecovery true + // on the next accepted Position. + Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(guid)); + Assert.False(record.IsSpatiallyProjected); + Assert.NotNull(record.WorldEntity); + Assert.Equal(SourceCell, record.FullCellId); + + const uint WireCell = SourceLandblock | 0x0002u; + fixture.Controller.OnPosition(fixture.Update( + new Vector3(13f, 15f, SpawnHeight), + WireCell, + teleportSequence: 1, + guid: guid)); + + // The recovery branch really ran and really declined — without this + // the assertions below could pass on an entity that never needed + // recovering at all. + Assert.True(fixture.Lifetime.Entities.TryGetActive( + guid, out RuntimeEntityRecord canonical)); + // The packet really was accepted and really did reach the recovery + // branch, which really did decline — without these the cell assertions + // below could pass on a rejected packet, or on an entity that never + // needed recovering at all. + Assert.Equal( + WireCell, + canonical.Snapshot.Position!.Value.LandblockId); + Assert.True(fixture.MaterializerDeclined); + + // W2 is what closes both halves. + Assert.True(record.IsSpatiallyProjected); + Assert.Equal(WireCell, record.FullCellId); + Assert.Equal(WireCell, canonical.FullCellId); + Assert.Equal(WireCell, fixture.Entity.ParentCellId); + } + // ── Scenario 1: teleport commit ───────────────────────────────────── [Theory] @@ -966,14 +1057,27 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests private readonly uint _guid; private readonly bool _nullClassification; + /// + /// C5b review L1/L2: true once the hydration controller's materializer + /// was actually reached and DECLINED. Only meaningful when the fixture + /// was built with decliningMaterializer: true. + /// + internal bool MaterializerDeclined => _decliningMaterializer?.Declined + ?? false; + + private readonly DecliningMaterializer? _decliningMaterializer; + internal Fixture( uint guid, bool nullClassification = false, bool withAnimation = false, - bool isMissile = false) + bool isMissile = false, + bool decliningMaterializer = false) { _guid = guid; _nullClassification = nullClassification; + _decliningMaterializer = + decliningMaterializer ? new DecliningMaterializer() : null; var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; engine.AddLandblock( SourceLandblock, @@ -1221,7 +1325,8 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests Runtime, Lifetime, new object(), - new NoopMaterializer(), + (ILiveEntityProjectionMaterializer?)_decliningMaterializer + ?? new NoopMaterializer(), new NoopRelationships(), new NoopReadyPublisher(), new AlwaysKnownOrigin(), @@ -1521,6 +1626,36 @@ public sealed class LiveEntityNetworkOnPositionCollapseMatrixTests public void ForgetUnknownOwner(uint serverGuid) { } } + /// + /// C5b review L1/L2: models the post-C5b + /// DatLiveEntityProjectionMaterializer self-projection branch + /// DECLINING (DatLiveEntityProjectionMaterializer.cs:767-787's + /// expectedCanonical.FullCellId != 0u gate not being satisfied), + /// which is the state C5b's contract §14 item (1) says production now + /// reaches. Records that it was reached so the test cannot pass + /// vacuously, and returns — the value + /// OnPosition's recovery call site deliberately ignores. + /// + private sealed class DecliningMaterializer + : ILiveEntityProjectionMaterializer + { + internal bool Declined { get; private set; } + + public bool TryMaterialize( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? + appearanceUpdate = null) + { + Declined = true; + return false; + } + + public void ResetSessionState() => Declined = false; + } + private sealed class NoopMaterializer : ILiveEntityProjectionMaterializer { public bool TryMaterialize( diff --git a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs index 45ff40bb..40e55de7 100644 --- a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs @@ -386,30 +386,35 @@ public sealed class InboundPhysicsStateControllerTests // The oracle: the production classifier, fed the same wire packet and // the same static HasAnimations proxy. + // + // C5b review L3 (2026-08-05): the request is now BUILT by the + // production constructor rather than hand-written. The old version + // passed `HasAnimations: animated` as a literal, so the merge's + // `old.MotionTableId ?? old.Physics?.MotionTableId` and + // RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot + // equivalent were textually identical and pinned by nothing — either + // could have been edited without a red test. Routing the oracle + // through Build makes the equality of the two expressions the thing + // this matrix actually asserts. + using RuntimeEntityObjectLifetime oracle = OracleLifetime(); + RuntimeEntityRecord canonical = RegisterOracleSnapshot(oracle, seed); RuntimeAuthoritativePositionRoute route = RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( - new RuntimeAcceptedPositionRouteRequest( - new RuntimeAuthoritativePositionAuthority( - new RuntimeGenerationToken(7), - new RuntimeEntityKey(guid, 1), - PositionAuthorityVersion: 4UL, - AcceptedPositionSequence: update.PositionSequence, - timestamps.PreviousTeleport, - timestamps.Teleport, - disposition), + RuntimeAcceptedPositionRouteRequests.Build( + new RuntimeGenerationToken(7), + canonical, + new RuntimeEntityKey(guid, 1), + update, isLocalPlayer ? RuntimePositionEntityKind.LocalPlayer : RuntimePositionEntityKind.Remote, RuntimeAcceptedPositionSource.PositionEvent, - update.Position, - PlacementFrame: update.PlacementId, - PositionPackVelocity: null, - CommittedCellId: 0x0101FFFFu, - HasContact: true, - PlayerDistance: 0f, - UsePositionFromServer: true, - HasAnimations: animated, - default)); + disposition, + timestamps.PreviousTeleport, + timestamps.Teleport, + playerDistance: 0f, + usePositionFromServer: true, + committedCellId: 0x0101FFFFu)); // Rebuild what the classified route would have installed, through the // SAME production merge, and require field equality. @@ -938,6 +943,142 @@ public sealed class InboundPhysicsStateControllerTests teleport, forcePosition); + /// + /// C5b review L4 (2026-08-05). The merge's HasAnimations proxy is + /// old.MotionTableId ?? old.Physics?.MotionTableId — a coalesce + /// whose whole point (per the production comment on + /// RuntimeAcceptedPositionRouteRequests.Build) is that + /// WeenieDescription and ObjDesc merges populate only ONE of the two + /// fields, depending on which stage last touched appearance vs + /// description. Every pre-existing fixture set both halves to the same + /// value, so deleting either operand of the ?? was undetectable + /// while the production comment claimed the mixed case is the real-world + /// one. These rows are the mixed cases, and the oracle is still + /// Build + the production classifier, so the two independent + /// copies of the coalesce stay pinned equal on exactly the inputs that + /// discriminate between them. + /// + /// The explicit-zero row is the third discriminator: a present-but- + /// zero top half is NOT null, so ?? never reaches the nonzero + /// physics half and the entity is correctly unanimated. Swapping the + /// coalesce operands would flip that row alone. + /// + [Theory] + // topLevelMotionTableId, physicsMotionTableId, expectedAnimated + [InlineData(null, 0x09000001u, true)] + [InlineData(0x09000001u, null, true)] + [InlineData(0x09000001u, 0x09000002u, true)] + [InlineData(null, null, false)] + [InlineData(0u, 0x09000001u, false)] + [InlineData(null, 0u, false)] + public void MixedMotionTableSourcesDriveTheSamePlacementFrameGateAsTheRoute( + uint? topLevelMotionTableId, + uint? physicsMotionTableId, + bool expectedAnimated) + { + const uint guid = 0x80000041u; + WorldSession.EntitySpawn seed = WithTimestamps( + Spawn(guid, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u), + teleport: 10, + forcePosition: 0); + seed = seed with + { + PlacementId = 7, + MotionTableId = topLevelMotionTableId, + Physics = seed.Physics!.Value with + { + AnimationFrame = 7, + MotionTableId = physicsMotionTableId, + }, + }; + + WorldSession.EntityPositionUpdate update = new( + guid, + Position(0x0101FFFFu, 20f), + Velocity: null, + PlacementId: 5, + IsGrounded: true, + InstanceSequence: 3, + PositionSequence: 11, + TeleportSequence: 10, + ForcePositionSequence: 0); + + var merging = new InboundPhysicsStateController(); + merging.AcceptCreate(seed); + Assert.True(merging.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn merged, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + using RuntimeEntityObjectLifetime oracle = OracleLifetime(); + RuntimeEntityRecord canonical = RegisterOracleSnapshot(oracle, seed); + RuntimeAuthoritativePositionRoute route = + RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition( + RuntimeAcceptedPositionRouteRequests.Build( + new RuntimeGenerationToken(7), + canonical, + new RuntimeEntityKey(guid, 1), + update, + RuntimePositionEntityKind.Remote, + RuntimeAcceptedPositionSource.PositionEvent, + disposition, + timestamps.PreviousTeleport, + timestamps.Teleport, + playerDistance: 0f, + usePositionFromServer: true, + committedCellId: 0x0101FFFFu)); + + // The classified route's own gate agrees with the fixture's intent… + Assert.Equal(!expectedAnimated, route.ApplyPlacementFrameBeforeRouting); + // …and the merge, which computed its own copy, lands the SAME frame. + // Animated => retail's HasAnims gate @0x00454137 skips SetPlacementFrame + // entirely, so the seed's 7 survives; unanimated => the wire's 5 lands. + Assert.Equal(expectedAnimated ? 7u : 5u, merged.PlacementId); + Assert.Equal( + expectedAnimated ? 7u : 5u, + merged.Physics!.Value.AnimationFrame); + } + + /// + /// A minimal Runtime lifetime whose only purpose is to own a canonical + /// so the production + /// RuntimeAcceptedPositionRouteRequests.Build can be the oracle + /// (C5b review L3). The fixture landblock matches the 0x0101FFFF cells + /// every packet in this file uses. + /// + private static RuntimeEntityObjectLifetime OracleLifetime() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + 0x01010000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static RuntimeEntityRecord RegisterOracleSnapshot( + RuntimeEntityObjectLifetime lifetime, + WorldSession.EntitySpawn seed) + { + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(seed).Canonical!; + // The pin is only worth anything if the canonical snapshot really is + // the same spawn the merge retained. + Assert.Equal(seed.MotionTableId, canonical.Snapshot.MotionTableId); + Assert.Equal( + seed.Physics!.Value.MotionTableId, + canonical.Snapshot.Physics!.Value.MotionTableId); + return canonical; + } + private static WorldSession.EntitySpawn WithTimestamps( WorldSession.EntitySpawn spawn, ushort? movement = null, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs index 06a86490..3ca51715 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs @@ -97,6 +97,19 @@ public sealed class RuntimeSteadyStatePositionMergeTests /// Exactly ONE of each, never zero and never two — pinned at the /// observable, not at the source site. /// + /// C5b review D4-round fix (2026-08-05): this test used to pass + /// identically with C5b reverted and therefore proved nothing. Its + /// only delta assertion was Assert.Single(deltas, d => Rebucketed + /// && parentGuid), which FILTERS — so the pre-C5b world (merge + /// publishes Rebucketed, CommitRebucket then early-returns + /// publish-less because the cell already matches) and the post-C5b world + /// (merge publishes Updated, CommitRebucket publishes Rebucketed) + /// both satisfy it. The child arm was equally blind: + /// childSpatialBefore + 1UL holds in both worlds because whichever + /// site does NOT move the cell propagates idempotently. The discriminating + /// observable is the TOTAL ordered parent delta stream: + /// [Rebucketed] before, [Updated, Rebucketed] after. + /// /// Both parent classes per the #319 discipline: a test population /// that only ever sees creature parents is blind to the player class. /// @@ -138,11 +151,33 @@ public sealed class RuntimeSteadyStatePositionMergeTests Assert.True(lifetime.CommitRebucket(parent, OtherCell, LandblockSentinel)); Assert.Equal(OtherCell, parent.FullCellId); - RuntimeEntityDelta rebucketed = Assert.Single( - deltas, - d => d.Change is RuntimeEntityChange.Rebucketed - && d.Entity.Identity.ServerGuid == parentGuid); - Assert.Equal(OtherCell, rebucketed.Entity.CellId); + + // THE discriminating assertion: the complete ordered parent stream. + // The merge publishes Updated (it moved no cell); the prologue + // rebucket publishes the Rebucketed. Reverting C5b's + // `refreshPosition: false` collapses this to the single-element + // [Rebucketed] and this assertion fails on both count and order. + List parentDeltas = deltas + .Where(d => d.Entity.Identity.ServerGuid == parentGuid) + .ToList(); + Assert.Equal( + new[] + { + RuntimeEntityChange.Updated, + RuntimeEntityChange.Rebucketed, + }, + parentDeltas.Select(d => d.Change)); + // The Updated half carries the OLD cell alongside the NEW wire + // position — a torn pair that did not exist pre-C5b, because pre-C5b + // the merge stamped both together. Filed as AP-147. + Assert.Equal(Cell, parentDeltas[0].Entity.CellId); + Assert.Equal( + OtherCell, + parentDeltas[0].Entity.Position!.Value.ObjCellId); + Assert.Equal(OtherCell, parentDeltas[1].Entity.CellId); + Assert.Equal( + OtherCell, + parentDeltas[1].Entity.Position!.Value.ObjCellId); // #319's child-equality channel: exactly one propagation, and the // child lands on the parent's new cell. diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 16bdd4b4..72d19276 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1564,6 +1564,83 @@ public sealed class RuntimeSetPositionStateTests Assert.Equal(body.CellPosition.ObjCellId, record.FullCellId); } + /// + /// C5b review L5 (2026-08-05). C5b kept the merge's + /// beforeCell != canonical.FullCellId ? Rebucketed : Updated + /// ternary alive on the argument that the + /// Forget(restoreCancelledPark: true) above it can produce a real + /// 0 -> N cell edge INSIDE TryApplyPosition. Every + /// restoreCancelledPark test until now called Forget + /// directly, so the retained arm had zero coverage through the production + /// entry point and the argument was unpinned. This drives the real merge. + /// + /// The wire cell is deliberately the SOURCE cell while the park's + /// committed body cell is the DESTINATION: the restored residency — and + /// therefore the Rebucketed delta's value — can only have come + /// from the park rollback, never from the wire packet, which C5b withholds + /// (RuntimeSetPositionState.cs's RestoreParkWithdrawal writes + /// SetFullCell only while FullCellId == 0u, which is exactly + /// the state ParkDeferred left behind). + /// + [Fact] + public void AcceptedPositionCancellingWakeableParkPublishesRebucketedThroughTheMerge() + { + PhysicsEngine engine = FlatEngine(SourceLandblock, 0f); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + var generation = new RuntimeGenerationToken(1UL); + lifetime.BindEventContext(() => generation, static () => 1UL); + const uint guid = 0x7000411Fu; + RuntimeEntityRecord record = CreateRecord(lifetime, guid, 1); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + + RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(CrossLandblockRequest())); + Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status); + Assert.Equal(0u, record.FullCellId); + Assert.Equal(DestinationCell, body.CellPosition.ObjCellId); + + var observer = new EntityObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + + Assert.True(lifetime.TryApplyPosition( + new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition( + SourceCell, 11f, 21f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + // The park rolled back inside the merge: 0 -> the body's committed + // destination cell, which is NOT the wire cell. + Assert.Equal(DestinationCell, record.FullCellId); + Assert.NotEqual(SourceCell, record.FullCellId); + Assert.True(body.InWorld); + + // THE assertion the retained ternary owns. Collapsing it to a constant + // Updated turns this red; the wire-cell withhold cannot rescue it, + // because the edge came from the rollback. + RuntimeEntityDelta delta = Assert.Single( + observer.Deltas, + d => d.Entity.Identity.ServerGuid == guid); + Assert.Equal(RuntimeEntityChange.Rebucketed, delta.Change); + Assert.Equal(DestinationCell, delta.Entity.CellId); + } + /// /// The sibling cancel entry point. Route 2's controller and route 4b's /// remote controller both cancel a DeferredCell park through