fix(physics): restore presentation when a park is cancelled (#312)

Regression from 7f1c1f5a (C4 route 4b-2). A remote player who recalled in,
arrived, and stood still was permanently absent from the world render AND the
radar while remaining fully simulated — 71 healthy physics ticks with contact
and walkable, interpolation enqueues, equipment attached, chat visible.

Route 4b-2 is the first commit that lets an ordinary remote UpdatePosition open
a canonical SetPosition. A park publishes a synchronous Withdraw that tears down
presentation registrations; only TryPublishPlace restores them.
RestoreParkWithdrawal — added in the same slice — restores InWorld, the object
clock, and canonical residency, i.e. the Runtime half only. Eight Opus reviews
verified those three fields and the tests asserted exactly them, so the suite
stayed green while the entity was invisible.

Why it is intermittent: the presentation half IS restored incidentally by the
per-packet prologue rebucket for a MOVING remote. It only sticks when the
entity parks on its FINAL accepted Position and then goes idle, because ACE
stops broadcasting for a stationary entity, so no later packet arrives to
re-publish it and nothing else re-drives.

The fix publishes a RuntimePlacementProjectionKind.WithdrawalRestored receipt on
the one ordered placement stream, acknowledge-only in Runtime (the parked
operation is already retired by CancelCoreDeferred), which the App sink maps to
the exact inverse of its own TryPublishWithdrawal: the projection half (bucket,
IsSpatiallyProjected, IsSpatiallyVisible, spatial indexes, RefreshPresentation)
plus the publish half (_worldState, _worldEvents, _effectPoses,
_localPlayerShadow, visibility sinks). Applied with commitPose: false, because
the withdrawal never moved the sidecar; a test feeds a deliberately wrong
position to pin that.

Two alternatives were refuted on measurement, not preference. Routing the
restore's SetFullCell through CommitCanonicalCell cannot fire on the shipped
remote path at all — the prologue rebucket has already recommitted a non-zero
FullCellId before the merge cancels the park, so no cell edge remains — and it
never touches the publish half regardless. Extending RestoreParkWithdrawal
directly reduces to the same receipt, since Runtime must not reach behind the
host sink.

Gated on the entity ending the rollback canonically whole (FullCellId != 0 &&
InWorld) rather than on residencyRestored, which is false on the shipped remote
path and would have made the fix a no-op. AP-136's quiescing-prefix refusal arm
is preserved: no receipt, entity stays withdrawn.

Corrects my own framing of the defect: _worldState/_worldEvents/_effectPoses are
lost but are NOT what kills render and radar (_worldState is the plugin
IGameState; _effectPoses is the pose registry, not entity.MeshRefs). The
load-bearing casualties are the visibility sinks and the
IsSpatiallyProjected/IsSpatiallyVisible + bucket removal that gates the radar.

Register: AD-63 filed (selection deliberately not restored — user intent),
AP-136 amended (its "restored visible" claim covered only the canonical half;
the gap was a defect, not a divergence). ShadowObjectRegistry.Suspend stays
out of scope per AP-136.

Seven-revert discrimination table including one that proves the test is not
merely re-checking the bucket. Suite 11,023 passed / 4 skipped / 0 failed.

Live gate is user-run and folds into #309: two clients, ACDREAM_PROBE_PARK=1,
recall a remote in and let it stand still; acceptance is
[park-restore] ... presentation=True for that guid plus a visible model and a
radar blip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 11:21:45 +02:00
parent 204d0ae047
commit b1f914d508
10 changed files with 1086 additions and 12 deletions

View file

@ -72,6 +72,34 @@ public enum RuntimePlacementProjectionKind
/// see AcknowledgeProjection's dedicated branch.
/// </summary>
ExecutorCompleted,
/// <summary>
/// The <see cref="Withdraw"/> receipt a restorable park published has
/// been ROLLED BACK by <c>RestoreParkWithdrawal</c>: canonical residency,
/// <c>InWorld</c>, the transient bits, and the object clock are whole
/// again at the entity's committed cell, so every presentation
/// registration that <see cref="Withdraw"/> tore down must be re-installed
/// exactly as it stood.
///
/// <para>This exists because <c>RestoreParkWithdrawal</c> can only reach
/// CANONICAL state. The presentation half of a withdrawal (the graphical
/// bucket, the projection-visibility sinks, plugin world state/events, the
/// effect-pose registry, the local-player shadow) lives behind the host
/// sink, and its only mirror image is that sink's Place publication. Before
/// this receipt existed the rollback depended on a LATER <see cref="Place"/>
/// that a remote which stops moving never receives - ACE stops broadcasting
/// Positions for a stationary entity - leaving it simulated, collidable and
/// audible but invisible in the world and absent from the radar for the
/// rest of the session.</para>
///
/// <para>Acknowledge-only in Runtime, exactly like <see cref="Discard"/>
/// and <see cref="ExecutorCompleted"/>: it is published after the parked
/// operation has already been cancelled and retired, so there is no
/// operation to resume or commit against - see AcknowledgeProjection's
/// dedicated branch. A host must NEVER refuse it: a false return leaves it
/// at the FIFO head and wedges the whole ordered stream, which is strictly
/// worse than the invisibility it repairs.</para>
/// </summary>
WithdrawalRestored,
}
public readonly record struct RuntimePortalPlacementAuthority(
@ -1163,6 +1191,81 @@ internal sealed class RuntimeSetPositionState : IDisposable
return token;
}
/// <summary>
/// Publishes the exact inverse of the <see cref="ParkDeferred"/>
/// withdrawal receipt whose CANONICAL half
/// <see cref="RestoreParkWithdrawal"/> just rolled back, on the SAME
/// ordered stream every Place/Withdraw/Discard receipt uses.
///
/// <para><b>Why a receipt rather than a wider rollback.</b>
/// <see cref="ParkDeferred"/>'s withdrawal has two halves. The canonical
/// half (<c>InWorld</c>, transient bits, object clock, residency, spatial
/// root) is Runtime-owned and <see cref="RestoreParkWithdrawal"/> restores
/// it directly. The PRESENTATION half was performed by the host sink that
/// consumed the Withdraw receipt - the graphical bucket, the projection
/// visibility sinks, plugin world state and events, the effect-pose
/// registry, the local-player shadow - and Runtime cannot and must not
/// reach any of it. Its one existing mirror image is the sink's own Place
/// publication, so the rollback names it the same way the teardown was
/// named: with an ordered receipt.</para>
///
/// <para><b>Why not simply route the restore's SetFullCell through
/// CommitCanonicalCell</b> so the graphical <c>CellCommitted</c> ->
/// <c>RebucketLiveEntity</c> recovery fires: measured, that recovery
/// restores the bucket, <c>IsSpatiallyProjected</c>,
/// <c>IsSpatiallyVisible</c> and the projection-visibility observers, but
/// it never touches the plugin world state, the world-event stream, or the
/// effect-pose registry - only the sink's Place publication does. It also
/// cannot fire at all in the shipped remote path, where the per-packet
/// prologue rebucket has already recommitted a NON-ZERO
/// <c>record.FullCellId</c> before the merge cancels the park, so the
/// restore's residency arm is skipped and there is no cell edge to
/// commit.</para>
///
/// <para>NOT Operation-backed: the parked operation was removed and
/// retired by <c>CancelCoreDeferred</c> before this runs, so the token is
/// assembled from the canonical record's current facts exactly the way
/// <see cref="PublishExecutorCompletion"/> assembles one, and
/// <see cref="AcknowledgeProjection"/> consumes it through the same
/// acknowledge-only branch.</para>
///
/// <para>Ordering is the stream's, not ours: the cancelled park's own
/// Discard still sits at a LOWER sequence when this publishes, so the
/// synchronous dispatch below is a no-op and the host's per-frame
/// <c>RetryPending</c> pump delivers this receipt immediately after that
/// Discard drains. If the same packet then commits or re-parks the entity,
/// its Place/Withdraw lands at a HIGHER sequence and supersedes this
/// restoration in canonical order.</para>
/// </summary>
private void PublishWithdrawalRestoration(RuntimeEntityRecord record)
{
if (record.Key is not { } key)
return;
PhysicsBody? body = record.PhysicsBody;
ulong sequence = checked(++_nextProjectionSequence);
var token = new RuntimePlacementProjectionToken(
sequence,
Revision: 1UL,
key,
record.PositionAuthorityVersion,
record.SpatialAuthorityVersion,
record.PlacementCommitVersion,
_entities.SessionLifetimeVersion,
record.FullCellId,
_physics.ExpectedCollisionGeneration(record.FullCellId),
Portal: default);
var snapshot = new RuntimePlacementProjectionSnapshot(
token,
RuntimePlacementProjectionKind.WithdrawalRestored,
body?.Position ?? Vector3.Zero,
body?.Orientation ?? Quaternion.Identity,
body?.CellPosition.Frame.Origin ?? Vector3.Zero,
body?.InContact ?? false,
body?.OnWalkable ?? false);
_pendingProjection.Add(sequence, snapshot);
PublishPlacement(snapshot);
}
/// <summary>
/// F2: binds the ONE notification fired when a Kind ExecutorCompleted
/// receipt is acknowledged (mirrors
@ -3185,11 +3288,16 @@ internal sealed class RuntimeSetPositionState : IDisposable
return false;
}
if (pending.Kind is RuntimePlacementProjectionKind.Discard
or RuntimePlacementProjectionKind.ExecutorCompleted)
or RuntimePlacementProjectionKind.ExecutorCompleted
or RuntimePlacementProjectionKind.WithdrawalRestored)
{
// C0-1: an ExecutorCompleted receipt is never Operation-backed
// (see PublishExecutorCompletion) - there is nothing to resume or
// commit against, exactly like Discard.
// commit against, exactly like Discard. A WithdrawalRestored
// receipt is published from RestoreParkWithdrawal, AFTER
// CancelCoreDeferred already removed and retired the parked
// operation (see PublishWithdrawalRestoration), so it is never
// Operation-backed either.
_pendingProjection.Remove(token.Sequence);
RetireQuiescenceProjectionSequence(token.Sequence);
if (pending.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
@ -3450,6 +3558,20 @@ internal sealed class RuntimeSetPositionState : IDisposable
/// cancelled park cannot leave the entity invisible and intangible with
/// nothing able to wake it.
///
/// <para><b>Presentation is rolled back too, through
/// <see cref="PublishWithdrawalRestoration"/>.</b> The canonical half
/// below is only one half of what <see cref="ParkDeferred"/>'s Withdraw
/// receipt removed; the host sink that consumed that receipt also dropped
/// the graphical bucket, the projection-visibility sinks, plugin world
/// state/events, the effect-pose registry and the local-player shadow, and
/// nothing but that sink's Place publication re-installs them. Relying on
/// a LATER Place was the defect: a remote that parks on its final Position
/// and then stops moving never receives one (ACE stops broadcasting for a
/// stationary entity), so it stayed simulated, collidable, audible - and
/// invisible in both the world and the radar for the rest of the session.
/// The restoration receipt is published exactly when the entity ends this
/// method canonically whole, so the two halves can never disagree.</para>
///
/// <para>Residency specifically is re-tested against the live quiescence
/// map HERE as well as at park time, because this runs on a later packet
/// for a retained park - see the inline comment for the window. The
@ -3542,12 +3664,27 @@ internal sealed class RuntimeSetPositionState : IDisposable
_physics.AcknowledgeSpatialProjection(record, spatial: true);
residencyRestored = true;
}
// The presentation rollback is gated on the entity actually ENDING
// this method canonically whole, not on `residencyRestored` alone.
// Those are different facts: in the shipped graphical remote path the
// per-packet prologue rebucket (LiveEntityNetworkUpdateController ->
// LiveEntityRuntime.RebucketLiveEntity) has already recommitted a
// non-zero FullCellId and re-acknowledged the spatial root BEFORE the
// merge cancels the park, so the arm above is correctly skipped while
// the entity is nonetheless whole and must be shown again. The
// converse - a quiescing prefix refusing residency (AP-136) - leaves
// FullCellId at zero, and the entity stays presentation-withdrawn to
// match, exactly as it stays canonically withdrawn.
bool canonicallyWhole = record.FullCellId != 0u
&& record.PhysicsBody is { InWorld: true };
if (canonicallyWhole)
PublishWithdrawalRestoration(record);
// Issue #309's connected-gate confirmation signal; see the [park]
// line's own comment in ParkDeferred.
if (PhysicsDiagnostics.ProbeParkEnabled)
{
Console.WriteLine(FormattableString.Invariant(
$"[park-restore] guid=0x{record.ServerGuid:X8} restoreCell=0x{residentCellId:X8} inWorld={withdrawal.InWorld} residency={residencyRestored}"));
$"[park-restore] guid=0x{record.ServerGuid:X8} restoreCell=0x{residentCellId:X8} inWorld={withdrawal.InWorld} residency={residencyRestored} presentation={canonicallyWhole}"));
}
}