fix(physics): restore a cancelled park instead of leaving the entity withdrawn

Shipped-code defect affecting committed route-2 code, found while reviewing
route 4b-1.

RuntimeSetPositionState.ParkDeferred withdraws an entity from the world:
body.InWorld = false, TransientStateFlags.Active cleared, WithdrawCanonical,
SuspendObjectClock. CancelCoreDeferred then removed the operation and rewrote
the pending Withdraw into a Discard while restoring NONE of it. So cancelling a
wakeable park was strictly worse than keeping one — the park is wakeable, the
cancel destroys the only object that could ever wake it, and the entity is left
invisible AND intangible with nothing to bring it back.

Route 2's re-issue funnel masked this: re-issuing is correct for a one-shot
ForcePosition ACE never repeats, and wrong for a repeated remote stream, so the
hole was hidden rather than fixed.

Retail's own answer is a working park, verified in the decomp rather than
assumed: CPhysicsObj::SetPositionInternal @0x00515BD0, when AdjustPosition
yields no cell @0x00515C1D, calls prepare_to_leave_visibility @0x00515CDA,
store_position @0x00515CE2 (the DESTINATION pose is committed), GotoLostCell
@0x00515CF2 registering at m_position.objcell_id read AFTER store_position (so
the destination cell), clears transient 0x80 @0x00515CF7, and returns OK
@0x00515D07. InitObjCell @0x00508260 drains the lost list on cell load and calls
reenter_visibility @0x00516250, which re-places from the object's OWN
m_position with flags 0x11.

Two corrections to the direction I gave, both forced by evidence and both right:

The pose must NOT be rolled back — only the withdrawal. Three shipped route-2
tests capture positionAtPark AFTER the park and assert it survives the cancel,
and retail agrees: store_position commits the destination and nothing
un-commits it. Restoring residency at the body's committed cell is therefore
retail's own cell choice, not merely self-consistent.

The gate defaults to FALSE with four explicit opt-ins, rather than defaulting
true with opt-outs at the withdrawal callers. That keeps every one of the ~20
shipped Forget/ForgetExactPlacement sites at exactly its current behaviour
instead of depending on having correctly enumerated the withdrawal transactions.
Review had already found the broad version corrupting five of them
(TryApplyPickup, CommitAcceptedParent, CommitAcceptedParentCellless,
CommitWithdrawal, CommitPositionChannelUpdate): they hand-roll a partial
re-withdrawal that undoes the clock and FullCellId but not InWorld or the
_spatialRoots re-registration, leaving a picked-up item both in inventory and an
InWorld cellless spatial root in the physics workset.

ParkDeferred's restorableOnCancel is opt-in for exactly one of its four callers
— the plain unplaceable-destination park. Every quiescence and retirement park
is excluded deliberately: those entities are withdrawn because their world is
going away, and restoring residency inside a quiescing prefix blocks its
retirement.

VerifyPositionChannelCancellation now asserts InWorld and IsSpatialRoot per
channel — Position is a cancellation and must restore; Pickup and Parent are
withdrawals and must not. It previously asserted only !IsDeferred and counts,
which is why five green states hid this.

Register row AP-136 measured against GotoLostCell/reenter_visibility rather than
labelled "retail-shaped". Files #309 (the restore-on-cancel residual, with
park-survives recorded as the retail-faithful target and its two blockers named:
the NewerPositionPickupAndParentEachCancelExactLostOperation invariant and
teardown convergence) and #310 (an unbounded retirement stall — a retained
preparation retry pins its prefix through HasOldPrefixPlacementDebt forever, and
TickLostCellDeadlines has no production caller so the 25 s timer never fires).

This is a user-observable change to shipped paths: restorableOnCancel: true sits
in SubmitPreparedPlacementCore, the shared core behind every production
placement. AP-136 and #309 carry the proposed two-client check.

Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline
10,938). Every new test discrimination-verified by reverting the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 04:07:39 +02:00
parent 9e97be1896
commit 634bc5513a
9 changed files with 666 additions and 10 deletions

View file

@ -24,6 +24,155 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## C4 route 4b-1 review — park lifecycle — 2026-08-04
#309 and #310 filed from the route 4b-1 dual-review round; #311 filed from
the delta-review round on the same route's remediation. Evidence:
[`2026-08-04-c4-route-4b-1-review-findings.md`](research/2026-08-04-c4-route-4b-1-review-findings.md).
## #309 — Cancelled lost-cell park re-shows the entity where retail would keep it hidden
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-08-04
**Component:** physics / placement
**Description:** When a `DeferredCell` park is cancelled by the accepted-Position
merge, we now roll the withdrawal back (`InWorld`, object clock, canonical
residency) so the entity is no longer stranded invisible-and-intangible. But the
entity becomes **visible immediately at the committed destination pose, without
collision**, whereas retail keeps it hidden and re-shows it only when the cell
loads.
**Root cause / status:** Retail's lost-cell mechanism has no cancel at all.
`CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose via
`store_position` @0x00515CE2 and registers the object with
`CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210). That 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), re-placing at the pose `store_position` committed.
An update that performs no SetPosition leaves the registration untouched.
So the retail-faithful end state is a park that **survives** cancellation. That
was implemented and reverted this round because it reverses a shipped, tested
invariant — `NewerPositionPickupAndParentEachCancelExactLostOperation`
(`tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs`, helper
`VerifyPositionChannelCancellation`) asserts `Assert.False(IsDeferred(record))`,
i.e. a newer Position cancels the park — and because surviving parks broke
`GameRuntime` teardown convergence (stage 10). Re-deciding that invariant plus
teardown convergence is the blocker.
Residual in practice: a remote at 5-10 Hz is superseded within ~150 ms. The case
that bites is a remote that teleports into a non-resident landblock and then
**stops moving**, because ACE stops broadcasting for a stationary entity.
Also unrestored: the `ShadowObjectRegistry.Suspend` applied by
`WithdrawCanonical`. Un-suspending requires a real placement dispatch
(`ReplacePositionRows`), so the entity rejoins the collision broadphase on its
next placement rather than at cancel time.
**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`.
**Acceptance:** Park survives a superseding no-placement Position and wakes via
`InitObjCell`-equivalent collision-generation arrival, with teardown converging
and the newer-Position invariant deliberately re-decided.
**Connected check required — this is NOT a no-behaviour-change slice.** The
route 4b-1 contract's "no connected gate" line does not apply to the park fix.
`restorableOnCancel: true` is set in `SubmitPreparedPlacementCore`, the shared
core behind EVERY production placement, and the merge-time
`restoreCancelledPark: true` is on the accepted-Position path every remote and
the local player traverse. So shipped behaviour changes for any entity whose
placement parks.
Proposed connected check (two clients, local ACE):
1. Walk the observed character to a landblock boundary so a remote sits in a
landblock the observer has not streamed, forcing a `DeferredCell` park.
2. Confirm the remote no longer vanishes permanently — the pre-fix symptom was
invisible AND intangible for the rest of the session.
3. Confirm it appears at the SERVER-authoritative destination pose, not at a
stale pre-park pose, and that it becomes collidable once the landblock
publishes.
4. Confirm the local player's own ForcePosition corrections (route 2) still
land unchanged — that path shares the same cancel.
Steps 1-3 are the user-visible acceptance for AP-136's residual.
## #310 — Retained preparation retry stalls landblock retirement with no bound
**Status:** OPEN
**Severity:** HIGH
**Filed:** 2026-08-04
**Component:** physics / streaming
**Description:** An entity holding a retained preparation retry keeps its
landblock prefix in placement debt, so
`TryAcquireCollisionPrefixMutationPermission` refuses on **every** poll and the
landblock never retires. There is no bound and no timeout.
**Root cause / status:** `HasOldPrefixPlacementDebt` refuses permission while any
affected root holds an operation, so `LandblockRetirementStage.Physics` never
completes and the retirement coordinator simply retries forever.
`TickLostCellDeadlines` — the only expiry that could break the cycle — has **no
production caller**, so its deadline never fires. The only thing that clears the
debt is an inbound packet for that same entity, which is exactly what a
`RetrySetupUnavailable` on an asset that never loads does not produce.
Pre-existing and independent of route 4b-1; 4b-1 does not bound it, it only
avoids widening it by declining to retain operations for destinations it cannot
service. Pinned by
`RuntimeCollisionPrefixQuiescenceTests.RetainedPreparationRetryStallsPrefixRetirementIndefinitely`
(1,000 consecutive refusals), which also shows retiring the operation is what
releases the prefix.
Note this is also why `ParkCollisionResidents`'s overlap throw is unreachable:
permission is refused before `ParkCollisionResidents` is ever entered.
**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
`HasOldPrefixPlacementDebt`, `TryAcquireCollisionPrefixMutationPermission`,
`TickLostCellDeadlines` (uncalled).
**Acceptance:** A retained preparation retry cannot block a landblock retirement
indefinitely — either the deadline is driven in production or the retirement can
proceed past stale placement debt.
## #311 — RetryPendingProjections allocates a fresh array on every non-empty call
**Status:** OPEN
**Severity:** LOW (perf, not correctness)
**Filed:** 2026-08-04
**Component:** physics / headless
**Description:** `RuntimeSetPositionState.RetryPendingProjections` (reached via
`RuntimePlacementProjectionChannel.RetryPending`
`RuntimePlacementProjectionSubscription.RetryPending`) snapshots the entire
pending-projection dictionary into a fresh array on every call —
`_pendingProjection.Values.ToArray()`. C4 route 4b-1's N3 fix
(`HeadlessSessionEventRoute.RetryPending`, wired from `HeadlessSessionHost.Tick`)
now reaches this path every headless host tick instead of once per session —
new per-tick pressure K4's 30-session resource envelope was not measured with.
**Root cause / status:** The empty-FIFO case is closed —
`RuntimePlacementProjectionSubscription.HasPendingReceipts` (added alongside
this issue) lets a host early-out before ever reaching
`RetryPendingProjections` when nothing is outstanding, which is the
overwhelming common case in steady state. The non-empty case still
allocates: every call that DOES have an outstanding receipt pays a fresh
`.ToArray()` copy. Closing it needs a non-allocating rewrite of
`RetryPendingProjections` itself (e.g. a reusable scratch buffer, mirroring
the `_driveScratch` pattern `RuntimeRemotePlacementDriveController.Advance`
and `RuntimeFirstEntryDriveController` already use) — deferred rather than
attempted in the C4 route 4b-1 delta-review session that filed this, whose
task scope held `RuntimeSetPositionState.cs` off-limits for that session
(a concurrent, separately-owned change was landing in the same file).
**Files:** `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs`
`RetryPendingProjections`. Call site:
`src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs``RetryPending`.
**Acceptance:** A headless tick with N&gt;0 outstanding placement receipts does
not allocate a new array per tick.
## Recent-regression cleanup — 2026-08-03
Plan: [`2026-08-03-recent-regression-cleanup.md`](plans/2026-08-03-recent-regression-cleanup.md).