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

@ -121,6 +121,168 @@ public sealed class RuntimePlacementPresentationSinkTests
Assert.Equal(Guid, Assert.Single(fixture.ClearedSelection));
}
/// <summary>
/// The invisible-recalled-remote regression, at the layer that produced
/// it. A restorable park publishes a <c>Withdraw</c> whose PRESENTATION
/// half only this sink performs; <c>RestoreParkWithdrawal</c> restores
/// canonical state and cannot reach any of it, and the per-packet
/// <c>RebucketLiveEntity</c> that used to mask the hole never runs for a
/// remote which parks on its final Position and then stops moving (ACE
/// stops broadcasting for a stationary entity). The entity stayed
/// simulated, collidable, and audible while absent from BOTH the world
/// render and the radar for the rest of the session.
///
/// <para>This test asserts on the presentation side specifically -
/// bucket residency, projection visibility, plugin world state, the
/// world-event replay set, the effect-pose registry, the local-player
/// shadow, and the visibility sinks. Re-checking <c>InWorld</c>, the
/// object clock, and residency (which the shipped Runtime tests already
/// cover) reproduces exactly the blind spot that shipped the bug.</para>
/// </summary>
[Fact]
public void WithdrawalRestored_ReinstatesEveryPresentationRegistrationTheWithdrawalRemoved()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
WorldEntity entity = Assert.IsType<WorldEntity>(record.WorldEntity);
AcDream.Plugin.Abstractions.WorldEntitySnapshot expectedSnapshot =
Assert.Single(fixture.WorldState.Entities);
LocalPlayerShadowState.Snapshot? expectedShadow =
fixture.LocalShadow.Current;
Assert.NotNull(expectedShadow);
Assert.Equal(
expectedSnapshot,
Assert.Single(CurrentEventMembership(fixture.WorldEvents)));
Assert.Equal(1, fixture.EffectPoses.Count);
Vector3 posedPosition = entity.Position;
Quaternion posedRotation = entity.Rotation;
RuntimePlacementProjectionSnapshot withdraw = Placement(
fixture,
record,
RuntimePlacementProjectionKind.Withdraw,
posedPosition,
posedRotation);
Assert.True(fixture.Sink.TryApply(in withdraw));
Assert.Empty(fixture.WorldState.Entities);
Assert.Empty(CurrentEventMembership(fixture.WorldEvents));
Assert.Equal(0, fixture.EffectPoses.Count);
RuntimeOwnershipSnapshot beforeRestore =
RuntimeOwnershipSnapshot.Capture(fixture.Runtime, record);
RuntimePlacementProjectionSnapshot restored = Placement(
fixture,
record,
RuntimePlacementProjectionKind.WithdrawalRestored,
// Deliberately NOT the entity's pose: the receipt is the exact
// inverse of a withdrawal, which never moved the sidecar, so the
// restoration must not move it either.
new Vector3(-777f, -777f, -777f),
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.25f));
Assert.True(fixture.Sink.TryApply(in restored));
// Graphical projection - what the world render and the radar read.
Assert.True(
record.IsSpatiallyProjected,
"restored park left the entity unprojected");
Assert.True(
record.IsSpatiallyVisible,
"restored park left the entity invisible");
Assert.Contains(record, fixture.Runtime.VisibleRecords);
Assert.True(
fixture.Spatial.IsLiveEntityProjectionResident(
record.ProjectionKey!.Value),
"restored park left the entity out of its draw bucket");
Assert.Equal(SourceCell, entity.ParentCellId);
// Sink-owned registrations - restored EXACTLY, not defaulted.
Assert.Equal(
expectedSnapshot,
Assert.Single(fixture.WorldState.Entities));
Assert.Equal(
expectedSnapshot,
Assert.Single(CurrentEventMembership(fixture.WorldEvents)));
Assert.Equal(1, fixture.EffectPoses.Count);
Assert.True(fixture.EffectPoses.TryGetRootPose(
entity.Id,
out Matrix4x4 restoredPose));
Assert.Equal(
Matrix4x4.CreateFromQuaternion(posedRotation)
* Matrix4x4.CreateTranslation(posedPosition),
restoredPose);
Assert.Equal(expectedShadow, fixture.LocalShadow.Current);
Assert.Equal(
[(record, false), (record, true)],
fixture.Visibility);
// The sidecar pose is untouched by the restoration.
Assert.Equal(posedPosition, entity.Position);
Assert.Equal(posedRotation, entity.Rotation);
// No canonical Runtime ownership moved.
Assert.Equal(
beforeRestore,
RuntimeOwnershipSnapshot.Capture(fixture.Runtime, record));
// Idempotent: re-applying the same immutable receipt (the documented
// IRuntimePlacementProjectionSink contract) changes nothing.
Assert.True(fixture.Sink.TryApply(in restored));
Assert.Equal(
expectedSnapshot,
Assert.Single(fixture.WorldState.Entities));
Assert.Equal(1, fixture.EffectPoses.Count);
Assert.True(record.IsSpatiallyVisible);
}
/// <summary>
/// A WithdrawalRestored receipt must NEVER be refused. Its caller
/// (<c>RuntimePlacementProjectionSubscription</c>) treats a false return
/// as "leave at the FIFO head", which would wedge the ordered stream for
/// every entity - strictly worse than the invisibility the receipt
/// repairs. Every way the restore can decline is an entity that is gone,
/// displaced, or unmaterialized, i.e. one with no prior projection left.
/// </summary>
[Fact]
public void WithdrawalRestored_IsAcknowledgedEvenWhenTheProjectionIsGone()
{
Fixture fixture = Fixture.Create();
LiveEntityRecord record = fixture.Materialize(Spawn(Guid, 1, SourceCell));
RuntimePlacementProjectionSnapshot restored = Placement(
fixture,
record,
RuntimePlacementProjectionKind.WithdrawalRestored,
Vector3.Zero,
Quaternion.Identity) with
{
Token = Placement(
fixture,
record,
RuntimePlacementProjectionKind.WithdrawalRestored,
Vector3.Zero,
Quaternion.Identity).Token with
{
SessionLifetimeVersion =
fixture.Runtime.SessionLifetimeVersion + 99UL,
},
};
Assert.True(fixture.Sink.TryApply(in restored));
Assert.Empty(fixture.Visibility);
}
private static List<AcDream.Plugin.Abstractions.WorldEntitySnapshot>
CurrentEventMembership(WorldEvents events)
{
var replayed = new List<AcDream.Plugin.Abstractions.WorldEntitySnapshot>();
void Handler(AcDream.Plugin.Abstractions.WorldEntitySnapshot snapshot) =>
replayed.Add(snapshot);
events.EntitySpawned += Handler;
events.EntitySpawned -= Handler;
return replayed;
}
[Fact]
public void Discard_IsAckOnlyNoOpEvenWhenTokenIsStaleOrSidecarIsGone()
{

View file

@ -1571,6 +1571,131 @@ public sealed class RuntimeSetPositionStateTests
/// has to live at the shared <c>CancelCore</c> layer or the identical
/// stranded-entity hole stays open on exactly the routes that produce it.
/// </summary>
/// <summary>
/// The canonical rollback above is only HALF of what the park's Withdraw
/// receipt removed. Its presentation half - the graphical bucket, the
/// projection-visibility sinks, plugin world state/events, the effect-pose
/// registry, the local-player shadow - was performed by the host sink that
/// consumed that receipt, and <c>RestoreParkWithdrawal</c> cannot reach any
/// of it. Before this receipt existed the only thing that re-installed it
/// was a LATER <c>Place</c>, which a remote that parks on its final
/// Position and then stops moving never receives: ACE stops broadcasting
/// for a stationary entity. The observable result was a fully simulated,
/// collidable, audible player that was absent from both the world render
/// and the radar for the rest of the session.
///
/// <para>So the cancel must publish the inverse receipt itself, on the one
/// ordered stream, and it must be acknowledge-only: the parked operation
/// is already removed and retired by <c>CancelCoreDeferred</c> when this
/// publishes.</para>
/// </summary>
[Fact]
public void CancellingWakeableParkPublishesTheWithdrawalRestorationReceipt()
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Du, 1);
PhysicsBody body = AttachBody(lifetime, record, SourceCell);
var placements = new PlacementObserver();
using IDisposable subscription =
lifetime.Events.SubscribePlacement(placements);
RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(CrossLandblockRequest()));
Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status);
Assert.Equal(
RuntimePlacementProjectionKind.Withdraw,
Assert.Single(placements.Deltas).Placement.Kind);
_ = lifetime.Physics.SetPosition.Forget(
record,
restoreCancelledPark: true);
Assert.True(body.InWorld);
Assert.NotEqual(0u, record.FullCellId);
// Two receipts are pending, in canonical order: the cancelled park's
// own Discard at the lower sequence, then the restoration.
Assert.Equal(2, lifetime.Physics.SetPosition.PendingProjectionCount);
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot head));
Assert.Equal(RuntimePlacementProjectionKind.Discard, head.Kind);
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
head.Token));
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot restoration));
Assert.Equal(
RuntimePlacementProjectionKind.WithdrawalRestored,
restoration.Kind);
Assert.Equal(record.Key, restoration.Token.Entity);
Assert.Equal(record.FullCellId, restoration.Token.ExactCellId);
Assert.Equal(body.Position, restoration.WorldPosition);
// The host's per-frame RetryPending pump is what delivers it, exactly
// as it delivers any receipt that was not the head when published.
placements.Deltas.Clear();
lifetime.Physics.SetPosition.RetryPendingProjections();
Assert.Equal(
restoration,
Assert.Single(placements.Deltas).Placement);
// Acknowledge-only: no operation backs it, and the stream drains.
Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(
restoration.Token));
Assert.Equal(
0,
lifetime.Physics.SetPosition.PendingProjectionCount);
}
/// <summary>
/// AP-136's refusal arm stays exact: when the committed cell's prefix is
/// retiring, the rollback declines residency, and presentation must stay
/// withdrawn to match rather than showing an entity Runtime deliberately
/// left out of the world.
/// </summary>
[Fact]
public void CancellingParkIntoQuiescingPrefixPublishesNoRestorationReceipt()
{
PhysicsEngine engine = FlatEngine(SourceLandblock, 0f);
using var lifetime = new RuntimeEntityObjectLifetime(engine);
RuntimeEntityRecord record = CreateRecord(lifetime, 0x7000411Eu, 1);
PhysicsBody body = AttachBody(lifetime, record, SourceCell);
RuntimeSetPositionOutcome parked = lifetime.Physics.SetPosition.Apply(
record,
record.PositionAuthorityVersion,
Command(CrossLandblockRequest()));
Assert.Equal(RuntimeSetPositionStatus.DeferredCell, parked.Status);
// Round 4 (D6)'s window: the prefix was clean when the park was taken
// (so the withdrawal WAS captured) and starts retiring before the
// cancel lands, which is only reachable because a retained park's
// restore runs on a later packet.
uint parkedCell = body.CellPosition.ObjCellId;
_ = lifetime.Physics.BeginCollisionPrefixQuiescence(
(parkedCell & 0xFFFF0000u) | 0xFFFFu,
collisionGeneration: 1UL,
includeOutdoorCells: true);
int pendingBefore =
lifetime.Physics.SetPosition.PendingProjectionCount;
_ = lifetime.Physics.SetPosition.Forget(
record,
restoreCancelledPark: true);
Assert.Equal(0u, record.FullCellId);
// Only the cancelled park's Discard rewrote an existing entry; no new
// restoration receipt was appended.
Assert.Equal(
pendingBefore,
lifetime.Physics.SetPosition.PendingProjectionCount);
Assert.True(lifetime.Physics.SetPosition.TryPeekProjection(
out RuntimePlacementProjectionSnapshot head));
Assert.Equal(RuntimePlacementProjectionKind.Discard, head.Kind);
}
[Fact]
public void CancellingWakeableParkByExactTokenAlsoRestoresTheEntity()
{