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

@ -319,6 +319,95 @@ public sealed partial class RuntimeCollisionPrefixQuiescenceTests
submitted.Status);
}
/// <summary>
/// C4 route 4b-1 review finding B2, pinned. The contract asked whether
/// <c>ParkCollisionResidents</c>'s overlap throw stays unreachable under a
/// design that lets remotes hold placement operations. It does, and for a
/// STRUCTURAL reason rather than an incidental one:
/// <c>TryAcquireCollisionPrefixMutationPermission</c> consults
/// <c>HasOldPrefixPlacementDebt</c> FIRST and refuses permission outright,
/// so <c>ParkCollisionResidents</c> is never entered while any affected
/// root holds an operation. (Additionally, a parked record has
/// <c>FullCellId == 0</c>, so it is not an affected resident at all.)
///
/// <para><b>The real hazard is not a throw, it is an unbounded streaming
/// stall — and this test PINS it as a stall, not as a pass.</b> A retained
/// preparation retry keeps its landblock prefix in placement debt, so the
/// retirement is refused on EVERY poll and the landblock never retires.
/// There is no bound: the retirement coordinator simply retries, and
/// <c>TickLostCellDeadlines</c> — the only expiry that could break the
/// cycle — has NO production caller, so its deadline never fires. The only
/// thing that clears it is an inbound packet for that same entity, which
/// is exactly what a <c>RetrySetupUnavailable</c> on an asset that never
/// loads does not produce.</para>
///
/// <para>This is a pre-existing hazard independent of route 4b-1, filed as
/// its own issue. 4b-1 does NOT bound it; it only avoids widening it, by
/// declining to retain operations for destinations it cannot service.</para>
/// </summary>
[Fact]
public void RetainedPreparationRetryStallsPrefixRetirementIndefinitely()
{
using var fixture = new Fixture();
RuntimeEntityRecord record = fixture.Add(
0x7000300Bu,
1,
CellP,
new Vector3(10f, 28f, 7f));
// A retained preparation retry: begun, never prepared — the shape a
// RetrySetupUnavailable on an asset that never resolves leaves behind.
RuntimeEntityPlacementToken retained = fixture.Lifetime.Physics
.SetPosition.BeginAcceptedPlacement(
record,
record.PositionAuthorityVersion,
RuntimeSetPositionOperationKind.RemoteAuthoritative);
Assert.True(retained.IsValid);
RuntimeCollisionPrefixQuiescenceToken token = fixture.Begin(2UL);
// The production retirement path, polled hard. Permission is refused
// every single time; nothing in the system advances it.
for (int poll = 0; poll < 1_000; poll++)
{
Assert.False(
fixture.TryAcquire(token, out _),
$"retirement unexpectedly acquired permission on poll {poll}; "
+ "if this now succeeds the stall has been bounded and "
+ "this test's pinned decision must be revisited");
}
// ParkCollisionResidents was never entered, so its overlap throw could
// not fire — the contract's item 6, proven structurally.
Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(record));
Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
out _));
// And the discriminator: retiring the retained operation is what
// releases the prefix. Once the debt is gone the ordinary two-phase
// handshake proceeds — ParkCollisionResidents withdraws the residents
// and permission follows the withdrawal acknowledgements — so drain
// those exactly as the production host does.
_ = fixture.Lifetime.Physics.SetPosition.ForgetExactPlacement(retained);
bool acquired = false;
for (int poll = 0; poll < 32 && !acquired; poll++)
{
acquired = fixture.TryAcquire(token, out _);
if (acquired)
break;
if (fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot projection))
{
Assert.True(fixture.Lifetime.Physics.SetPosition
.AcknowledgeProjection(projection.Token));
}
}
Assert.True(
acquired,
"clearing the retained preparation retry must let the prefix "
+ "retire; if it does not, the stall has a second cause");
}
[Fact]
public void QueriedNeighborPrefixHoldsResultWithoutRequestDependency()
{