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>
355 lines
16 KiB
C#
355 lines
16 KiB
C#
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.World;
|
|
using AcDream.App.Physics;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.Core.Plugins;
|
|
using AcDream.Core.World;
|
|
using AcDream.Plugin.Abstractions;
|
|
|
|
namespace AcDream.App.World;
|
|
|
|
/// <summary>
|
|
/// Graphical projection sink for canonical Runtime SetPosition receipts. It
|
|
/// owns only App-facing world, effect-pose, cached-local-shadow, selection,
|
|
/// and renderer/VFX visibility projections. Runtime physics, shadows, body
|
|
/// state, clocks, and worksets were committed before this sink is invoked.
|
|
///
|
|
/// This adapter deliberately owns no subscription. The exact graphical
|
|
/// session route owns the shared Runtime observer and its generation-scoped
|
|
/// update-thread retry lease.
|
|
/// </summary>
|
|
internal sealed class RuntimePlacementPresentationSink
|
|
: IRuntimePlacementProjectionSink
|
|
{
|
|
private readonly LiveEntityRuntime _liveEntities;
|
|
private readonly RuntimeWorldTransitState _transit;
|
|
private readonly WorldGameState _worldState;
|
|
private readonly WorldEvents _worldEvents;
|
|
private readonly EntityEffectPoseRegistry _effectPoses;
|
|
private readonly LocalPlayerShadowSynchronizer _localPlayerShadowSync;
|
|
private readonly Func<uint> _localPlayerGuid;
|
|
private readonly Action<uint> _clearSelectionForUnavailableEntity;
|
|
private readonly Action<LiveEntityRecord, bool>[] _visibilitySinks;
|
|
|
|
public RuntimePlacementPresentationSink(
|
|
LiveEntityRuntime liveEntities,
|
|
RuntimeWorldTransitState transit,
|
|
WorldGameState worldState,
|
|
WorldEvents worldEvents,
|
|
EntityEffectPoseRegistry effectPoses,
|
|
LocalPlayerShadowSynchronizer localPlayerShadowSync,
|
|
Func<uint> localPlayerGuid,
|
|
Action<uint> clearSelectionForUnavailableEntity,
|
|
IEnumerable<Action<LiveEntityRecord, bool>>? visibilitySinks = null)
|
|
{
|
|
_liveEntities = liveEntities
|
|
?? throw new ArgumentNullException(nameof(liveEntities));
|
|
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
|
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
|
_worldEvents = worldEvents ?? throw new ArgumentNullException(nameof(worldEvents));
|
|
_effectPoses = effectPoses
|
|
?? throw new ArgumentNullException(nameof(effectPoses));
|
|
// 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
|
|
?? throw new ArgumentNullException(
|
|
nameof(clearSelectionForUnavailableEntity));
|
|
_visibilitySinks = visibilitySinks?.ToArray()
|
|
?? Array.Empty<Action<LiveEntityRecord, bool>>();
|
|
if (_visibilitySinks.Any(static sink => sink is null))
|
|
throw new ArgumentException(
|
|
"Presentation visibility sinks cannot contain null.",
|
|
nameof(visibilitySinks));
|
|
}
|
|
|
|
public bool TryApply(in RuntimePlacementProjectionSnapshot projection)
|
|
{
|
|
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
|
|
{
|
|
// C3c: the initial-Create completion receipt is the graphical
|
|
// binding point for a residence-driven placement (the F1
|
|
// acknowledge-and-ignore behavior applied only while
|
|
// PublishExecutorCompletion had zero production callers;
|
|
// RuntimeInitialCreateContinuationExecutor's Released arm is one
|
|
// today).
|
|
return TryApplyInitialCreateCompletion(in projection);
|
|
}
|
|
|
|
if (projection.Kind
|
|
is RuntimePlacementProjectionKind.WithdrawalRestored)
|
|
{
|
|
return TryApplyWithdrawalRestoration(in projection);
|
|
}
|
|
|
|
if (projection.Kind is RuntimePlacementProjectionKind.Place
|
|
or RuntimePlacementProjectionKind.Withdraw
|
|
&& _liveEntities.HasActiveInitialCreateResidence(
|
|
projection.Token.Entity))
|
|
{
|
|
// C3c: a Place/Withdraw for an entity still holding its
|
|
// initial-create residence belongs to the first-entry conductor
|
|
// machinery, which acknowledges its own receipts at the exact
|
|
// FIFO head. Leave it there — the drive controller's pump
|
|
// consumes it; applying or acknowledging here would starve the
|
|
// conductor's own acknowledgement stage forever.
|
|
return false;
|
|
}
|
|
|
|
if (projection.Kind is RuntimePlacementProjectionKind.Place
|
|
&& !_transit.IsCurrentPlacementAuthority(
|
|
projection.Token.Portal,
|
|
projection.Token.ExactCellId))
|
|
{
|
|
// B2 review fix (2026-08-05): acknowledge-and-ignore, same shape
|
|
// as Discard/ExecutorCompleted/WithdrawalRestored above. A Place
|
|
// whose portal authority went stale (the transit ended or was
|
|
// superseded WHILE a DeferredCell park sat outstanding — the
|
|
// residual A1's readiness-hold does not close, since it only
|
|
// protects the ORDINARY in-flight case) must not be left
|
|
// refused at the FIFO head: RuntimePlacementProjectionSubscription
|
|
// .OnPlacement never calls Acknowledge on a `false` return, so a
|
|
// refused receipt wedges EVERY later entity's placement receipt
|
|
// behind it forever. This runs SYNCHRONOUSLY at publish
|
|
// (RuntimeAcceptedPositionDriveController's A2 re-validation, by
|
|
// contrast, only runs downstream of a receipt this gate ALREADY
|
|
// let through — it cannot protect this path). The canonical
|
|
// body already committed via RetryDeferred; there is simply no
|
|
// live presentation authority left to apply it to.
|
|
return true;
|
|
}
|
|
|
|
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
|
|
return false;
|
|
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
|
|
{
|
|
// Discard cancels only an unacknowledged observation - no
|
|
// world/presentation mutation. Must NOT fall through to the
|
|
// record-lookup gate below (that gate legitimately rejects for
|
|
// OTHER reasons, and this sink's caller
|
|
// (RuntimePlacementProjectionSubscription) treats a false return
|
|
// as "leave at the FIFO head" - a rejected Discard would
|
|
// permanently wedge the whole ordered stream).
|
|
return true;
|
|
}
|
|
if (!_liveEntities.TryGetRecord(
|
|
projection.Token.Entity,
|
|
out LiveEntityRecord record)
|
|
|| record.WorldEntity is not { } entity)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return projection.Kind switch
|
|
{
|
|
RuntimePlacementProjectionKind.Place =>
|
|
TryPublishPlace(record, entity),
|
|
RuntimePlacementProjectionKind.Withdraw =>
|
|
TryPublishWithdrawal(record, entity),
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c: binds one completed initial-Create drain's presentation. A
|
|
/// celless completion (a route that performed no SetPosition — a
|
|
/// deferred-parent child staying invisible until its parent replay, or a
|
|
/// positionless create) and a missing/superseded sidecar are
|
|
/// acknowledge-only; the sidecar's own materialization self-projects
|
|
/// from canonical state in those cases. Pending (not-yet-loaded)
|
|
/// destination buckets are allowed — the legacy Create path's own
|
|
/// semantics — so this receipt can never wedge the ordered stream behind
|
|
/// an unloaded graphical backend.
|
|
/// </summary>
|
|
private bool TryApplyInitialCreateCompletion(
|
|
in RuntimePlacementProjectionSnapshot projection)
|
|
{
|
|
if (projection.Token.ExactCellId == 0u)
|
|
return true;
|
|
if (!_liveEntities.TryApplyInitialCreateCompletionPresentation(
|
|
in projection))
|
|
{
|
|
return false;
|
|
}
|
|
if (!_liveEntities.TryGetRecord(
|
|
projection.Token.Entity,
|
|
out LiveEntityRecord record)
|
|
|| record.WorldEntity is not { } entity)
|
|
{
|
|
return true;
|
|
}
|
|
return TryPublishPlace(record, entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rolls back a <see cref="RuntimePlacementProjectionKind.Withdraw"/> this
|
|
/// sink already applied, after Runtime restored the canonical half of the
|
|
/// park it belonged to. Every registration
|
|
/// <see cref="TryPublishWithdrawal"/> dropped is re-installed by its exact
|
|
/// mirror image <see cref="TryPublishPlace"/> - the graphical bucket and
|
|
/// projection visibility through
|
|
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementProjection</c>, then plugin
|
|
/// world state, the world-event stream, the effect-pose registry, the
|
|
/// local-player shadow, and the presentation visibility sinks here.
|
|
///
|
|
/// <para><b>Always acknowledges.</b> Like Discard and ExecutorCompleted
|
|
/// this receipt is not Operation-backed, and its caller
|
|
/// (<c>RuntimePlacementProjectionSubscription</c>) treats a false return as
|
|
/// "leave at the FIFO head" - which would wedge the entire ordered stream
|
|
/// for every entity. Every way the restore below can decline is an
|
|
/// entity that is gone, displaced by a newer incarnation, or not
|
|
/// materialized, i.e. one with no prior projection left to restore; the
|
|
/// replacement projects itself through its own receipts.</para>
|
|
///
|
|
/// <para><b>Selection is deliberately not re-established.</b> The
|
|
/// withdrawal's <c>_clearSelectionForUnavailableEntity</c> is a
|
|
/// user-intent mutation, not a projection registration; re-selecting an
|
|
/// object on the player's behalf would invent input. Recorded as AD-63 in
|
|
/// the divergence register.</para>
|
|
/// </summary>
|
|
private bool TryApplyWithdrawalRestoration(
|
|
in RuntimePlacementProjectionSnapshot projection)
|
|
{
|
|
if (_liveEntities.TryApplyRuntimePlacementProjection(in projection)
|
|
&& _liveEntities.TryGetRecord(
|
|
projection.Token.Entity,
|
|
out LiveEntityRecord record)
|
|
&& record.WorldEntity is { } entity)
|
|
{
|
|
_ = TryPublishPlace(record, entity);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private bool TryPublishPlace(LiveEntityRecord record, WorldEntity entity)
|
|
{
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
|
|
WorldEntitySnapshot snapshot = Snapshot(entity);
|
|
_worldState.Add(snapshot);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
_worldEvents.UpsertCurrent(snapshot);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
_effectPoses.PublishMeshRefs(entity);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
|
|
if (record.ServerGuid == _localPlayerGuid())
|
|
{
|
|
// 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,
|
|
force: true);
|
|
}
|
|
|
|
for (int i = 0; i < _visibilitySinks.Length; i++)
|
|
{
|
|
_visibilitySinks[i](record, true);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private bool TryPublishWithdrawal(
|
|
LiveEntityRecord record,
|
|
WorldEntity entity)
|
|
{
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
|
|
for (int i = 0; i < _visibilitySinks.Length; i++)
|
|
{
|
|
_visibilitySinks[i](record, false);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
}
|
|
|
|
_worldState.RemoveById(entity.Id);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
_worldEvents.ForgetEntity(entity.Id);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
_effectPoses.Remove(entity.Id);
|
|
if (!IsCurrent(record, entity))
|
|
return false;
|
|
if (record.ServerGuid == _localPlayerGuid())
|
|
{
|
|
// 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);
|
|
return IsCurrent(record, entity);
|
|
}
|
|
|
|
private bool IsCurrent(LiveEntityRecord record, WorldEntity entity) =>
|
|
_liveEntities.TryGetRecord(
|
|
record.ProjectionKey!.Value,
|
|
out LiveEntityRecord current)
|
|
&& ReferenceEquals(current, record)
|
|
&& ReferenceEquals(current.WorldEntity, entity);
|
|
|
|
private static WorldEntitySnapshot Snapshot(WorldEntity entity) => new(
|
|
entity.Id,
|
|
entity.SourceGfxObjOrSetupId,
|
|
entity.Position,
|
|
entity.Rotation);
|
|
}
|