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:
parent
9e97be1896
commit
634bc5513a
9 changed files with 666 additions and 10 deletions
149
docs/ISSUES.md
149
docs/ISSUES.md
|
|
@ -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>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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue