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; /// /// 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. /// 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 _localPlayerGuid; private readonly Action _clearSelectionForUnavailableEntity; private readonly Action[] _visibilitySinks; public RuntimePlacementPresentationSink( LiveEntityRuntime liveEntities, RuntimeWorldTransitState transit, WorldGameState worldState, WorldEvents worldEvents, EntityEffectPoseRegistry effectPoses, LocalPlayerShadowSynchronizer localPlayerShadowSync, Func localPlayerGuid, Action clearSelectionForUnavailableEntity, IEnumerable>? 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>(); 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, }; } /// /// 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. /// 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); } /// /// Rolls back a this /// sink already applied, after Runtime restored the canonical half of the /// park it belonged to. Every registration /// dropped is re-installed by its exact /// mirror image - the graphical bucket and /// projection visibility through /// LiveEntityRuntime.TryApplyRuntimePlacementProjection, then plugin /// world state, the world-event stream, the effect-pose registry, the /// local-player shadow, and the presentation visibility sinks here. /// /// Always acknowledges. Like Discard and ExecutorCompleted /// this receipt is not Operation-backed, and its caller /// (RuntimePlacementProjectionSubscription) 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. /// /// Selection is deliberately not re-established. The /// withdrawal's _clearSelectionForUnavailableEntity 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. /// 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); }