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
|
|
@ -314,6 +314,23 @@ public sealed class RuntimeEntityDirectory
|
|||
record.SuspendObjectClock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-activates a clock suspended by <see cref="SuspendObjectClock"/>, for
|
||||
/// rolling back a withdrawal that is being cancelled rather than
|
||||
/// completed. NOT an exact inverse — the retained sub-quantum time is
|
||||
/// discarded, which is retail's <c>set_active(1)</c> rebase semantic; see
|
||||
/// <see cref="RuntimeEntityRecord.ResumeObjectClock"/>. Distinct from
|
||||
/// <see cref="ResetObjectClockForEnterWorld"/>, which is the
|
||||
/// entering-the-world edge and additionally rebases the static/dynamic
|
||||
/// quantum shape; a rollback re-activates a clock that never conceptually
|
||||
/// left.
|
||||
/// </summary>
|
||||
public void ResumeObjectClock(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
record.ResumeObjectClock();
|
||||
}
|
||||
|
||||
public void ResetObjectClockForEnterWorld(RuntimeEntityRecord record, bool isStatic)
|
||||
{
|
||||
EnsureKnown(record);
|
||||
|
|
|
|||
|
|
@ -1706,7 +1706,17 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
|
|||
RuntimePlacementCancellationReceipt cancellation = default;
|
||||
if (acceptedPosition)
|
||||
{
|
||||
cancellation = Physics.SetPosition.Forget(canonical);
|
||||
// A CANCELLATION, not a withdrawal: a newer accepted Position
|
||||
// supersedes the in-flight placement but the entity stays in the
|
||||
// world. If that placement was a DeferredCell park, cancelling it
|
||||
// without rolling the withdrawal back left the entity invisible
|
||||
// AND intangible with nothing able to wake it - see
|
||||
// RuntimeSetPositionState.Forget. Every OTHER Forget call in this
|
||||
// class is a withdrawal transaction and deliberately does not opt
|
||||
// in.
|
||||
cancellation = Physics.SetPosition.Forget(
|
||||
canonical,
|
||||
restoreCancelledPark: true);
|
||||
}
|
||||
Entities.RefreshSnapshot(
|
||||
canonical,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,29 @@ public sealed class RuntimeEntityRecord
|
|||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-activates a clock suspended by <see cref="SuspendObjectClock"/>.
|
||||
///
|
||||
/// <para>Deliberately NOT an exact inverse:
|
||||
/// <see cref="RetailObjectQuantumClock.Deactivate"/> preserves the
|
||||
/// retained sub-quantum <c>_pending</c> time while
|
||||
/// <see cref="RetailObjectQuantumClock.Activate"/> zeroes it, so resuming
|
||||
/// DISCARDS whatever fraction of a quantum was outstanding at suspend.
|
||||
/// That is the retail semantic, not an oversight — retail's
|
||||
/// <c>set_active(1)</c> rebases <c>update_time</c> to the current timer so
|
||||
/// the reactivation frame does not catch up suppressed time.</para>
|
||||
///
|
||||
/// <para>The epoch is also asymmetric on purpose: suspend bumps
|
||||
/// unconditionally, resume bumps only on a real inactive-to-active edge
|
||||
/// (Activate's own return contract), so a redundant resume is not
|
||||
/// observable as a clock change.</para>
|
||||
/// </summary>
|
||||
internal void ResumeObjectClock()
|
||||
{
|
||||
if (ObjectClock.Activate())
|
||||
ObjectClockEpoch++;
|
||||
}
|
||||
|
||||
internal void ResetObjectClockForEnterWorld(bool isStatic)
|
||||
{
|
||||
ObjectClock.ResetForEnterWorld(isStatic);
|
||||
|
|
|
|||
|
|
@ -410,6 +410,16 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
internal bool EnteringWorldFromCelllessResidence { get; set; }
|
||||
internal bool DormantLocalActivation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The entity state <see cref="ParkDeferred"/> overwrote, captured
|
||||
/// BEFORE it snapped the body to the (unplaceable) destination and
|
||||
/// ran <see cref="WithdrawCanonical"/>. <see cref="Forget"/> rolls
|
||||
/// back to it when it cancels a wakeable park - see
|
||||
/// <see cref="RestoreParkWithdrawal"/> for why the rollback target is
|
||||
/// the PRE-PARK pose and never the destination.
|
||||
/// </summary>
|
||||
internal ParkWithdrawal ParkWithdrawal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// #284: why this operation's mover preparation is currently parked,
|
||||
/// or <see cref="RuntimeSetPositionParkReason.None"/> when it is not.
|
||||
|
|
@ -483,10 +493,22 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
DormantLocalActivation = false;
|
||||
ParkReason = RuntimeSetPositionParkReason.None;
|
||||
PreparedCommandAwaitingWithdrawalAck = null;
|
||||
ParkWithdrawal = default;
|
||||
InPool = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact set of entity state <see cref="ParkDeferred"/> withdraws,
|
||||
/// captured before it is overwritten so <see cref="Forget"/> can roll a
|
||||
/// cancelled park back instead of stranding the entity.
|
||||
/// </summary>
|
||||
private readonly record struct ParkWithdrawal(
|
||||
bool Captured,
|
||||
bool InWorld,
|
||||
TransientStateFlags TransientState,
|
||||
bool ClockActive);
|
||||
|
||||
private sealed class CollisionPrefixQuiescence
|
||||
{
|
||||
internal required RuntimeCollisionPrefixQuiescenceToken Token
|
||||
|
|
@ -1288,8 +1310,15 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
ForgetPlacementCompletionCore(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="restoreCancelledPark"/> — see <see cref="Forget"/>. It
|
||||
/// defaults to false so every shipped caller keeps its exact prior
|
||||
/// behaviour; only a caller that is genuinely CANCELLING a placement
|
||||
/// intent (rather than withdrawing the entity from the world) opts in.
|
||||
/// </summary>
|
||||
internal RuntimePlacementCancellationReceipt ForgetExactPlacement(
|
||||
in RuntimeEntityPlacementToken token)
|
||||
in RuntimeEntityPlacementToken token,
|
||||
bool restoreCancelledPark = false)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ForgetPlacementCompletionCore(token);
|
||||
|
|
@ -1303,7 +1332,10 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
// against `_operations` immediately above with nothing reentrant in
|
||||
// between - passing it straight through is equivalent to (and safer
|
||||
// than) re-deriving it from `operation`.
|
||||
return CancelCore(token.Entity, token);
|
||||
return CancelCore(
|
||||
token.Entity,
|
||||
token,
|
||||
restoreCancelledPark: restoreCancelledPark);
|
||||
}
|
||||
|
||||
internal RuntimeEntityPlacementToken TryBeginExclusiveAuthoredPlacement(
|
||||
|
|
@ -3016,7 +3048,10 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
_preparedMovers[operation.Key] = canonicalRequest;
|
||||
|
||||
if (result.IsDeferred)
|
||||
return ParkDeferred(operation, result);
|
||||
// The one restorable park: the destination is not placeable right
|
||||
// now. Every other ParkDeferred caller is a quiescence/retirement
|
||||
// withdrawal - see ParkDeferred's restorableOnCancel doc.
|
||||
return ParkDeferred(operation, result, restorableOnCancel: true);
|
||||
|
||||
if (!CommitCanonical(operation, result))
|
||||
{
|
||||
|
|
@ -3302,22 +3337,155 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancelling a wakeable lost-cell park ROLLS THE ENTITY BACK. Without
|
||||
/// that, <see cref="ParkDeferred"/>'s withdrawal (<c>body.InWorld =
|
||||
/// false</c>, object clock suspended, <see cref="WithdrawCanonical"/>
|
||||
/// clearing canonical residency) outlived the operation that was the only
|
||||
/// thing able to wake it: <see cref="CancelCoreDeferred"/> removes the
|
||||
/// operation and restores none of that, so the entity was left invisible
|
||||
/// AND intangible for the rest of the session. The producing sequence is
|
||||
/// mundane - the accepted-Position merge
|
||||
/// (<c>RuntimeEntityObjectLifetime.TryApplyPosition</c>) calls this on
|
||||
/// EVERY accepted Position, so packet N parked and packet N+1 destroyed
|
||||
/// the park ~150 ms later, before any collision-generation wake could
|
||||
/// fire.
|
||||
///
|
||||
/// <para>This also closes the same hole on route 2's shipped
|
||||
/// <c>DeferredCell</c> path, which was masked only by its re-issue funnel
|
||||
/// - correct for a one-shot ForcePosition, absent for a repeated remote
|
||||
/// stream.</para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// <paramref name="restoreCancelledPark"/> distinguishes a CANCELLATION
|
||||
/// from a WITHDRAWAL, and defaults to false so every shipped caller keeps
|
||||
/// its exact prior behaviour.
|
||||
///
|
||||
/// <para>Only a cancellation may roll a park back. Most callers of this
|
||||
/// method are withdrawal transactions - pickup, parent attach, explicit
|
||||
/// withdrawal, position-channel teardown - which deliberately take the
|
||||
/// entity OUT of the world and then hand-roll their own partial
|
||||
/// re-withdrawal. Rolling a park back underneath one of those would set
|
||||
/// <c>body.InWorld = true</c> and re-register the entity in
|
||||
/// <c>_spatialRoots</c>, neither of which their cleanup undoes (it only
|
||||
/// zeroes the clock and <c>FullCellId</c>) - leaving e.g. a picked-up item
|
||||
/// simultaneously in inventory AND an <c>InWorld</c> cell-less spatial root
|
||||
/// handed to the <c>CPhysics::UseTime</c> workset by
|
||||
/// <c>CopySpatialRootsTo</c>, which filters only on key and
|
||||
/// <c>IsCurrent</c>.</para>
|
||||
///
|
||||
/// <para>The rollback exists because <see cref="ParkDeferred"/>'s
|
||||
/// withdrawal otherwise outlives the operation that was the only thing
|
||||
/// able to wake it: <see cref="CancelCoreDeferred"/> restores neither
|
||||
/// <c>InWorld</c>, the object clock, nor canonical residency, so the entity
|
||||
/// was left invisible AND intangible for the rest of the session. The
|
||||
/// accepted-Position merge produced that every ~150 ms - packet N parked,
|
||||
/// packet N+1 destroyed the park before any collision-generation wake could
|
||||
/// fire. It closes the same hole on route 2's shipped <c>DeferredCell</c>
|
||||
/// path, which was masked only by its re-issue funnel.</para>
|
||||
/// </summary>
|
||||
internal RuntimePlacementCancellationReceipt Forget(
|
||||
RuntimeEntityRecord record,
|
||||
bool releasePreparedMover = false)
|
||||
bool releasePreparedMover = false,
|
||||
bool restoreCancelledPark = false)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
RuntimePlacementCancellationReceipt receipt = default;
|
||||
if (record.Key is { } key)
|
||||
{
|
||||
// Captured before CancelCore, which retires the operation to the
|
||||
// pool and resets every field on it.
|
||||
ParkWithdrawal withdrawal =
|
||||
restoreCancelledPark
|
||||
&& _operations.TryGetValue(key, out Operation? parked)
|
||||
&& parked.WakeableLostCell
|
||||
&& ReferenceEquals(parked.Record, record)
|
||||
? parked.ParkWithdrawal
|
||||
: default;
|
||||
receipt = CancelCore(key);
|
||||
if (releasePreparedMover)
|
||||
_preparedMovers.Remove(key);
|
||||
if (withdrawal.Captured)
|
||||
RestoreParkWithdrawal(record, withdrawal);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Undoes <see cref="ParkDeferred"/>'s WITHDRAWAL - <c>InWorld</c>, the
|
||||
/// transient-state bits, the object clock, and canonical residency - so a
|
||||
/// cancelled park cannot leave the entity invisible and intangible with
|
||||
/// nothing able to wake it.
|
||||
///
|
||||
/// <para><b>The committed pose is deliberately left alone.</b>
|
||||
/// <see cref="ParkDeferred"/> snaps the body to the destination before
|
||||
/// withdrawing, which is precisely retail's lost-cell behaviour:
|
||||
/// <c>CPhysicsObj::SetPositionInternal</c> @0x00515BD0 calls
|
||||
/// <c>store_position</c> @0x00515CE2 on the no-cell branch and returns
|
||||
/// <c>OK_SPE</c>. Retail commits the destination pose and never rolls it
|
||||
/// back, and route 2's shipped tests pin the same contract by capturing
|
||||
/// the pose AFTER the park and asserting it survives the cancel. Residency
|
||||
/// is therefore restored at the body's committed cell, keeping residency
|
||||
/// and pose consistent, rather than at the stale pre-park cell.</para>
|
||||
///
|
||||
/// <para>Re-entering at a destination whose collision is not yet published
|
||||
/// cannot re-arm <see cref="ParkCollisionResidents"/>'s overlap throw:
|
||||
/// that throw fires only for a spatial root still holding an ACTIVE
|
||||
/// operation, and the cancel this restore follows has already retired the
|
||||
/// operation. An ordinary retirement re-parks the entity through the
|
||||
/// normal path instead.</para>
|
||||
///
|
||||
/// <para><b>Residual divergence, measured against retail rather than
|
||||
/// labelled retail-shaped.</b> Retail would keep the object hidden and
|
||||
/// re-show it on cell load - a lost registration is removed ONLY by
|
||||
/// <c>CObjectMaint::InitObjCell</c> @0x00508260, which drains the lost
|
||||
/// list and calls <c>CPhysicsObj::reenter_visibility</c> @0x00508296
|
||||
/// (@0x00516250). We instead re-show it immediately at the committed
|
||||
/// destination pose, uncollidable until its landblock publishes. For a
|
||||
/// remote at 5-10 Hz the next packet supersedes within ~150 ms; a remote
|
||||
/// that teleports into a non-resident landblock and then STOPS MOVING
|
||||
/// holds that state, because ACE stops broadcasting for a stationary
|
||||
/// entity. The retail-faithful end state is a park that SURVIVES
|
||||
/// cancellation, blocked on re-deciding the newer-Position-cancels-the-park
|
||||
/// invariant pinned by
|
||||
/// <c>NewerPositionPickupAndParentEachCancelExactLostOperation</c> and on
|
||||
/// teardown convergence. See the divergence register row and its issue.</para>
|
||||
///
|
||||
/// <para>The shadow-object suspension <see cref="WithdrawCanonical"/>
|
||||
/// applied is NOT lifted here: <c>ShadowObjectRegistry.Suspend</c> drops
|
||||
/// the owner's cell rows and only a real placement dispatch
|
||||
/// (<c>ReplacePositionRows</c>) rebuilds them, so un-suspending would mean
|
||||
/// re-running a placement re-entrantly from inside a cancel. The entity is
|
||||
/// restored visible and simulated and rejoins the collision broadphase on
|
||||
/// its next placement - recorded with the divergence above, not papered
|
||||
/// over.</para>
|
||||
/// </summary>
|
||||
private void RestoreParkWithdrawal(
|
||||
RuntimeEntityRecord record,
|
||||
in ParkWithdrawal withdrawal)
|
||||
{
|
||||
if (!_entities.IsCurrent(record))
|
||||
return;
|
||||
uint residentCellId = 0u;
|
||||
if (record.PhysicsBody is { } body)
|
||||
{
|
||||
body.InWorld = withdrawal.InWorld;
|
||||
body.TransientState = withdrawal.TransientState;
|
||||
residentCellId = body.CellPosition.ObjCellId;
|
||||
}
|
||||
if (withdrawal.ClockActive)
|
||||
_entities.ResumeObjectClock(record);
|
||||
if (residentCellId != 0u && record.FullCellId == 0u)
|
||||
{
|
||||
_entities.SetFullCell(
|
||||
record,
|
||||
residentCellId,
|
||||
(residentCellId & 0xFFFF0000u) | 0xFFFFu);
|
||||
_physics.AcknowledgeSpatialProjection(record, spatial: true);
|
||||
}
|
||||
}
|
||||
|
||||
internal void LeaveWorld(RuntimeEntityRecord record)
|
||||
{
|
||||
EnsureNotDisposed();
|
||||
|
|
@ -4085,14 +4253,39 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
/// so <see cref="Operation.CollisionQuiescenceHeld"/> derived false and
|
||||
/// the parked operation skipped the QuiescenceHeld stage entirely.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// <paramref name="restorableOnCancel"/> opts this park into
|
||||
/// <see cref="Forget"/>'s rollback. It is true for exactly one caller:
|
||||
/// the plain "destination is not placeable right now" park, which is the
|
||||
/// one that strands an entity when a later packet cancels it.
|
||||
///
|
||||
/// <para>It is deliberately FALSE for every quiescence/retirement park.
|
||||
/// Those withdraw the entity precisely because its own landblock prefix
|
||||
/// is being retired or held, so restoring canonical residency there would
|
||||
/// re-admit a spatial root into the prefix that is trying to quiesce and
|
||||
/// block the retirement outright - a streaming stall traded for a
|
||||
/// stranded entity. Those entities are withdrawn because their world is
|
||||
/// going away, not because a destination was momentarily unavailable.</para>
|
||||
/// </summary>
|
||||
private RuntimeSetPositionOutcome ParkDeferred(
|
||||
Operation operation,
|
||||
in PhysicsSetPositionResult result,
|
||||
bool publishImmediately = true,
|
||||
ulong? collisionGenerationOverride = null,
|
||||
uint? collisionPrefixOverride = null)
|
||||
uint? collisionPrefixOverride = null,
|
||||
bool restorableOnCancel = false)
|
||||
{
|
||||
PhysicsBody body = operation.Body!;
|
||||
// Captured BEFORE the withdrawal below clears it. Only the
|
||||
// WITHDRAWAL is captured, never the pose: the SnapToCell on the next
|
||||
// line deliberately commits the destination pose and that commit
|
||||
// stands, exactly as retail's store_position @0x00515CE2 commits it
|
||||
// on the lost-cell branch and never un-commits it.
|
||||
operation.ParkWithdrawal = new ParkWithdrawal(
|
||||
Captured: restorableOnCancel,
|
||||
InWorld: body.InWorld,
|
||||
TransientState: body.TransientState,
|
||||
ClockActive: operation.Record.ObjectClock.IsActive);
|
||||
body.Orientation = result.Orientation;
|
||||
body.SnapToCell(
|
||||
result.CellId,
|
||||
|
|
@ -5216,18 +5409,31 @@ internal sealed class RuntimeSetPositionState : IDisposable
|
|||
private RuntimePlacementCancellationReceipt CancelCore(
|
||||
RuntimeEntityKey key,
|
||||
in RuntimeEntityPlacementToken expectedToken,
|
||||
bool preserveLostFamily = false)
|
||||
bool preserveLostFamily = false,
|
||||
bool restoreCancelledPark = false)
|
||||
{
|
||||
if (!_operations.TryGetValue(key, out Operation? current)
|
||||
|| current.Token != expectedToken)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
// Opt-in only - see Forget's doc comment for why a withdrawal must
|
||||
// never roll a park back. This overload is the path route 2's
|
||||
// controller and route 4b's remote controller cancel through
|
||||
// (ForgetExactPlacement); they opt in, the initial-create residence
|
||||
// and continuation executors deliberately do not.
|
||||
RuntimeEntityRecord parkedRecord = current.Record;
|
||||
ParkWithdrawal withdrawal =
|
||||
restoreCancelledPark && current.WakeableLostCell
|
||||
? current.ParkWithdrawal
|
||||
: default;
|
||||
_ = CancelCoreDeferred(
|
||||
key,
|
||||
cancelLostFamily: false,
|
||||
preserveLostFamily,
|
||||
out RuntimePlacementProjectionSnapshot? discard);
|
||||
if (withdrawal.Captured)
|
||||
RestoreParkWithdrawal(parkedRecord, withdrawal);
|
||||
return discard is { } projection
|
||||
? new RuntimePlacementCancellationReceipt(projection)
|
||||
: default;
|
||||
|
|
|
|||
|
|
@ -307,8 +307,13 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
_pending = null;
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
setPosition.ForgetPlacementCompletion(pending.Token);
|
||||
// Cancellation, not withdrawal: the placement intent is abandoned but
|
||||
// the local player stays in the world, so a DeferredCell park must be
|
||||
// rolled back rather than left stranding the body.
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
setPosition.ForgetExactPlacement(pending.Token);
|
||||
setPosition.ForgetExactPlacement(
|
||||
pending.Token,
|
||||
restoreCancelledPark: true);
|
||||
if (cancellation.IsValid)
|
||||
setPosition.PublishCancellation(cancellation);
|
||||
}
|
||||
|
|
@ -796,8 +801,11 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
RuntimeSetPositionState setPosition,
|
||||
in RuntimeEntityPlacementToken token)
|
||||
{
|
||||
// Cancellation, not withdrawal - see AbandonPending.
|
||||
RuntimePlacementCancellationReceipt cancellation =
|
||||
setPosition.ForgetExactPlacement(token);
|
||||
setPosition.ForgetExactPlacement(
|
||||
token,
|
||||
restoreCancelledPark: true);
|
||||
if (cancellation.IsValid)
|
||||
setPosition.PublishCancellation(cancellation);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue