fix(physics): route local-player shadow presentation through SyncPose (#318, AP-145)

RuntimePlacementPresentationSink.TryPublishPlace previously published the
local player's collision-shadow pose with a direct LocalPlayerShadowState.Set
call — a plain cache write that never touched PhysicsEngine.ShadowObjects.
Because LocalPlayerShadowSynchronizer.SyncPose's own dedup check compares
against that same cache, the direct write could pre-seed the cache with the
destination pose and cause the next real SyncPose call to see "nothing
changed" and skip its own ShadowObjects publish — leaving the real collision
shadow at the pre-teleport position until an unrelated movement tick forced
a real publish.

Fix: TryPublishPlace now calls _localPlayerShadowSync.SyncPose(...,
force: true), the same publisher ordinary per-tick movement uses, so Place
always drives a real ShadowObjects write before the cache updates.
TryPublishWithdrawal carried the exact mirror asymmetry (a bare
LocalPlayerShadowState.Clear with no ShadowObjects.Suspend, leaving a live
phantom shadow row at the park's source cell for the whole park window — the
#184 shape) and is fixed in the same commit, same one-call shape:
_localPlayerShadowSync.Suspend(entity). The sink no longer holds a direct
LocalPlayerShadowState reference; both halves route exclusively through the
one synchronizer, which owns the cache internally.

The single LocalPlayerShadowSynchronizer instance is now constructed in
LivePresentationComposition (before the sink) and threaded through
LivePresentationResult to SessionPlayerComposition, which no longer builds
its own — this guarantees the sink's Place/Withdraw edge and ordinary
per-tick movement publish through the exact same publisher and cache rather
than two independent instances that could drift out of sync with each other.

TryPublishPlace's xmldoc now states the behavioural nuance directly: routing
through SyncPose means Place inherits SyncPose's own admission guard
(IsHidden, cellId == 0, not-current-visible-projection), which the old
direct .Set() call never consulted. Under those conditions SyncPose now
calls Suspend instead of publishing — correct and symmetric, but new
behaviour worth flagging at the call site, not just in a test comment.

RuntimePlacementShadowCompositionTests.cs (#318) proves four facts against
the real ShadowObjects registry, not the cache: a bare Place publishes a
real row at the destination cell with the source cell's row gone; a
subsequent ordinary per-tick Sync is then a correct no-op; a Place for a
registered non-local-player entity leaves its row at the source cell
untouched and never touches the player's cache (route 7 P4 — the fix lives
entirely inside the pre-existing player-only gate); and Withdraw suspends
the real registry row, not just the cache, with the retained
(suspendable) registration surviving for a later restore. All four were
sabotage-verified in both directions.

RuntimeForcePositionRenderCommitTests.cs (B2) drives a real end-to-end
accepted ForcePosition through RuntimeEntityObjectLifetime.TryApplyPosition
and RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition
against a live HostFixture, asserting both the committed render position
AND a cell change that deliberately crosses out of the spawn's outdoor grid
cell, so the cell assertion is independently falsifiable rather than riding
along with the position assertion.

Retires AP-145 (this fix) in docs/architecture/retail-divergence-register.md.
AP-1 and AD-1 are untouched by this commit — they retire separately in the
deletion-sweep commit that follows.

Evidence chain: docs/research/2026-08-05-c5a-contract.md (the governing C5a
slice contract), docs/research/2026-08-05-c5a-architecture-review.md (round
1, FAIL — three MAJORs: vacuous route-7 P4 test, unfixed Withdraw-side
mirror asymmetry, non-driving B2 test), docs/research/2026-08-05-c5a-architecture-review-round2.md
(round 2, PASS with two MINORs — an unfalsifiable B2 cell assertion and the
undocumented SyncPose guard nuance, both fixed here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-05 14:09:11 +02:00
parent 392c1e22c1
commit f8e55ba5e4
11 changed files with 2660 additions and 22 deletions

View file

@ -97,6 +97,12 @@ internal sealed record LivePresentationResult(
RenderSceneShadowRuntime? RenderSceneShadow,
LiveEntityRuntime LiveEntities,
RuntimePlacementPresentationSink PlacementProjection,
// AP-145 fix (2026-08-05, #318): constructed HERE (before the sink that
// consumes it) rather than later in SessionPlayerComposition, so both
// consumers share the ONE synchronizer instance / ONE
// LocalPlayerShadowState cache. Two instances would themselves
// reintroduce a cache-desync class this fix exists to close.
LocalPlayerShadowSynchronizer LocalPlayerShadowSynchronizer,
ProjectileController ProjectileController,
LiveEntityProjectionWithdrawalController ProjectionWithdrawal,
LiveEntityLightController Lights,
@ -491,13 +497,27 @@ internal sealed class LivePresentationCompositionPhase
if (visible)
entityEffects?.OnPresentationBound(record);
});
// AP-145 fix (2026-08-05, #318): constructed here, BEFORE the
// sink, so the sink can publish the local player's Place
// through the same seam ordinary per-tick movement uses rather
// than writing LocalPlayerShadowState directly. Threaded through
// to SessionPlayerComposition via LivePresentationResult so
// there remains exactly one synchronizer / one cache for the
// whole session — SessionPlayerComposition no longer constructs
// its own.
var localPlayerShadowSynchronizer = new LocalPlayerShadowSynchronizer(
d.PhysicsEngine,
liveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.LocalPlayerShadow);
var placementProjection = new RuntimePlacementPresentationSink(
liveEntities,
worldTransit,
d.WorldGameState,
d.WorldEvents,
d.EffectPoses,
d.LocalPlayerShadow,
localPlayerShadowSynchronizer,
() => d.PlayerIdentity.ServerGuid,
guid =>
{
@ -668,6 +688,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadowLease,
liveEntities,
placementProjection,
localPlayerShadowSynchronizer,
projectileController,
projectionWithdrawal,
lightsLease,
@ -717,6 +738,7 @@ internal sealed class LivePresentationCompositionPhase
RenderSceneShadowRuntime>? renderSceneShadowLease,
LiveEntityRuntime liveEntities,
RuntimePlacementPresentationSink placementProjection,
LocalPlayerShadowSynchronizer localPlayerShadowSynchronizer,
ProjectileController projectileController,
LiveEntityProjectionWithdrawalController projectionWithdrawal,
CompositionAcquisitionScope.CompositionAcquisitionLease<LiveEntityLightController> lightsLease,
@ -1196,6 +1218,7 @@ internal sealed class LivePresentationCompositionPhase
renderSceneShadow,
liveEntities,
placementProjection,
localPlayerShadowSynchronizer,
projectileController,
projectionWithdrawal,
lightsLease.Resource,

View file

@ -796,12 +796,12 @@ internal sealed class SessionPlayerCompositionPhase
d.AnimatedEntities,
live.AnimationPresenter,
content.AnimationHookFrames);
var localPlayerShadow = new LocalPlayerShadowSynchronizer(
d.PhysicsEngine,
live.LiveEntities,
d.PlayerIdentity,
d.WorldOrigin,
d.PlayerShadow);
// AP-145 fix (2026-08-05, #318): the ONE synchronizer instance is
// now constructed earlier, in LivePresentationComposition, so
// RuntimePlacementPresentationSink's Place edge and this session's
// ordinary per-tick movement publish through the exact same
// publisher / cache rather than two independent instances.
var localPlayerShadow = live.LocalPlayerShadowSynchronizer;
var localPlayerProjection = new LocalPlayerProjectionController(
new LiveLocalPlayerProjectionRuntime(
live.LiveEntities,

View file

@ -26,7 +26,7 @@ internal sealed class RuntimePlacementPresentationSink
private readonly WorldGameState _worldState;
private readonly WorldEvents _worldEvents;
private readonly EntityEffectPoseRegistry _effectPoses;
private readonly LocalPlayerShadowState _localPlayerShadow;
private readonly LocalPlayerShadowSynchronizer _localPlayerShadowSync;
private readonly Func<uint> _localPlayerGuid;
private readonly Action<uint> _clearSelectionForUnavailableEntity;
private readonly Action<LiveEntityRecord, bool>[] _visibilitySinks;
@ -37,7 +37,7 @@ internal sealed class RuntimePlacementPresentationSink
WorldGameState worldState,
WorldEvents worldEvents,
EntityEffectPoseRegistry effectPoses,
LocalPlayerShadowState localPlayerShadow,
LocalPlayerShadowSynchronizer localPlayerShadowSync,
Func<uint> localPlayerGuid,
Action<uint> clearSelectionForUnavailableEntity,
IEnumerable<Action<LiveEntityRecord, bool>>? visibilitySinks = null)
@ -49,8 +49,13 @@ internal sealed class RuntimePlacementPresentationSink
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
_effectPoses = effectPoses
?? throw new ArgumentNullException(nameof(effectPoses));
_localPlayerShadow = localPlayerShadow
?? throw new ArgumentNullException(nameof(localPlayerShadow));
// AP-145 fix (2026-08-05, #318, architecture review A2): BOTH the
// Place and Withdraw halves now route the local player's shadow
// exclusively through this ONE publisher (SyncPose / Suspend), which
// owns the LocalPlayerShadowState cache internally — this sink no
// longer needs a direct reference to the cache at all.
_localPlayerShadowSync = localPlayerShadowSync
?? throw new ArgumentNullException(nameof(localPlayerShadowSync));
_localPlayerGuid = localPlayerGuid
?? throw new ArgumentNullException(nameof(localPlayerGuid));
_clearSelectionForUnavailableEntity = clearSelectionForUnavailableEntity
@ -240,10 +245,46 @@ internal sealed class RuntimePlacementPresentationSink
if (record.ServerGuid == _localPlayerGuid())
{
_localPlayerShadow.Set(
// AP-145 fix (2026-08-05, #318): route through the SAME
// publisher ordinary per-tick movement uses
// (LocalPlayerShadowSynchronizer.SyncPose), not a direct
// LocalPlayerShadowState.Set. The old direct write updated only
// the dedup cache, never PhysicsEngine.ShadowObjects — the
// portal jump's real collision shadow stayed at the SOURCE cell
// until an unrelated movement tick happened to drift far enough
// to defeat SyncPose's own dedup check (which the direct write
// had just pre-seeded with the destination pose, so even that
// recovery could silently miss). SyncPose both publishes the
// real ShadowObjects row (via Register, which first deregisters
// any prior cell rows — no stale source-cell row, no duplicate)
// and records the dedup cache as its own last step, in the
// correct order. force:true because this IS the authoritative
// placement commit, not an ordinary per-tick refresh — it must
// never be skipped by the dedup path.
//
// Behaviour-change nuance (architecture review, 2026-08-05):
// routing through SyncPose means Place now inherits SyncPose's
// own admission guard — IsHidden(...), cellId == 0, or
// !IsCurrentVisibleProjection(entity) (not the current spatial
// root / not a current record) — none of which the old direct
// .Set() call ever consulted. Under any of those conditions
// SyncPose calls Suspend(entity) instead of publishing: the real
// ShadowObjects row is REMOVED and the cache is cleared, where
// the old write would have left a stale ShadowObjects row in
// place and simply overwritten the cache. This is the correct,
// symmetric behaviour — it is exactly what the very next
// ordinary per-tick Sync call would do in the same situation —
// and it is covered by the same player-only gate this method
// already had, but it IS new: a Place that lands while the
// record is momentarily not the current visible spatial root
// (a narrow, low-frequency window) now suspends the real shadow
// where it previously left a possibly-stale one untouched.
_localPlayerShadowSync.SyncPose(
entity,
entity.Position,
entity.Rotation,
record.FullCellId);
record.FullCellId,
force: true);
}
for (int i = 0; i < _visibilitySinks.Length; i++)
@ -279,7 +320,20 @@ internal sealed class RuntimePlacementPresentationSink
if (!IsCurrent(record, entity))
return false;
if (record.ServerGuid == _localPlayerGuid())
_localPlayerShadow.Clear();
{
// AP-145 fix, Withdraw half (2026-08-05, architecture review
// A2): the exact mirror of the Place-side fix. The old direct
// _localPlayerShadow.Clear() only cleared the dedup cache,
// leaving a LIVE phantom row in PhysicsEngine.ShadowObjects at
// the park's source cell for the whole park window (the #184
// shape) — every other entity's collision sweep in that cell
// would collide with a player who is, per every other acdream
// predicate, gone. Suspend() does both: real registry suspend
// (ShadowObjectRegistry.Suspend) AND the cache clear, in the
// one call LocalPlayerShadowSynchronizer already exposes for
// exactly this pairing (see its own Suspend/SyncPose split).
_localPlayerShadowSync.Suspend(entity);
}
if (!IsCurrent(record, entity))
return false;
_clearSelectionForUnavailableEntity(record.ServerGuid);