From 960373df2e86090d3fcf323bb9ecacac9677b637 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 09:43:15 +0200 Subject: [PATCH] feat(runtime): first-entry conductor sequences local-player world entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cutover slice C3a: the resumable transaction that dissolves the C3 flip's circularity finding. RuntimeLocalPlayerFirstEntryState drives the local player's complete entry in retail's own order — authored-mover preparation (the makeObject/set_description shape analog, via a pure no-submit extraction TryPrepareAuthoredMover), the publication chain's off-canonical Prepare + atomic body Commit against the residence's exact placement token, the Evaluate/CommitActivation enter-world analog, the Place-receipt acknowledgement as that act's virtualized completion, and only then the executor's FIFO drain (retail: enter_world at 93824 strictly precedes ProcessObjectNetBlobs at 93831). Five stages, eight typed statuses, exactly-once per stage under retry, no second token copies, and an acknowledge-stage discriminator that separates not-yet-FIFO-head (retryable) from authority-moved (typed abandonment) — a mid-flight delete can no longer strand a retry-forever entry. The residence retirement notification becomes an ordered multicast (snapshot-iterated per the event-stream precedent), the lifetime constructs the conductor with a late-bind Publication seam (transactional unbound failure — no mutation before the throw), deletion/reset converge the conductor automatically through the same choke points as the executor, and its active count is in the ownership snapshot and IsConverged. Dormant: no production Advance caller; GameRuntime binding is C3c's first act. Reviewed: retail-conformance PASS (the stage order verified step-for-step against retail's entry sequence; the live-controller-on- abandonment invariant proven structurally enforced and retail-correct — retail has no entry-flow rollback) + architecture/adversarial PASS after one fix round (acknowledge-stage authority discrimination; the wiring fold; two prescribed pre-C3c hardenings). Runtime 948/948; complete Release solution green across all nine projects. Co-Authored-By: Claude Fable 5 --- .../Entities/RuntimeEntityObjectLifetime.cs | 64 +- .../RuntimeInitialCreateResidenceState.cs | 54 +- .../RuntimeLocalPlayerFirstEntryState.cs | 690 +++++++++++++ .../Physics/RuntimeSetPositionState.cs | 68 +- ...RuntimeInitialCreateResidenceStateTests.cs | 47 + .../RuntimeLocalPlayerFirstEntryStateTests.cs | 922 ++++++++++++++++++ 6 files changed, 1816 insertions(+), 29 deletions(-) create mode 100644 src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index c3a1e293..f9c96d00 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -4,6 +4,7 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; namespace AcDream.Runtime.Entities; @@ -56,7 +57,14 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( /// SAME underlying receipt stream - unlike ReplayFailureCount above, /// this is NOT a diagnostic-only counter. /// - int PendingCompletionReceiptCount = 0) + int PendingCompletionReceiptCount = 0, + /// + /// C3a/F2: outstanding AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState + /// tracked keys - dormant (no production caller of its own + /// Advance), but fully constructed/wired like every other owner + /// here, so its own ownership must converge to zero the same way. + /// + int LocalPlayerFirstEntryActiveCount = 0) { public bool IsConverged => IsDisposed @@ -78,6 +86,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && InitialCreateResidenceLeaseCount == 0 && InitialCreateExecutorProgressCount == 0 && PendingCompletionReceiptCount == 0 + && LocalPlayerFirstEntryActiveCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -157,6 +166,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -165,6 +184,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -210,6 +235,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -218,6 +253,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -263,6 +304,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RegisterEntityWithInitialResidence(spawn, isLocalPlayer), (canonical, version, spawn, replaceGeneration) => ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // C3a/F2: dormant - no production caller of its own Advance - but + // constructed and wired exactly like the executor above so its + // ownership converges the same way. RuntimeLocalPlayerPhysicsPublicationState + // does not exist yet at this point (GameRuntime builds + // RuntimeLocalPlayerMovementState, then attaches its publication, + // only after this lifetime); BindPublication supplies it later. + LocalPlayerFirstEntry = new RuntimeLocalPlayerFirstEntryState( + InitialCreateResidences, + InitialCreateExecution, + Physics); // Round 3 B3: every residence retirement path - not only the // executor's own DiscardProgress calls - must converge the // executor's progress AND its separately-tracked pending @@ -271,6 +322,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // residence state referencing the executor type directly. InitialCreateResidences.BindRetirementNotification( key => InitialCreateExecution.DiscardProgress(key)); + // C3a/F2: the SAME multicast retirement notification also reaps the + // first-entry conductor's tracked progress - see + // RuntimeInitialCreateResidenceState.BindRetirementNotification's + // updated doc comment for why this is now multicast. + InitialCreateResidences.BindRetirementNotification( + key => LocalPlayerFirstEntry.Forget(key)); // F2: reaps the executor's completion-receipt correlation entry // exactly when a host acknowledges the ExecutorCompleted receipt it // correlates - mirrors the residence-retirement binding immediately @@ -295,6 +352,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { get; } internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution { get; } + internal RuntimeLocalPlayerFirstEntryState LocalPlayerFirstEntry { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -330,7 +388,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable parents.DeferredAcceptedRelationCount, InitialCreateExecution.ReplayFailureCount, InitialCreateExecution.LastReplayFailure is not null, - InitialCreateExecution.PendingCompletionReceiptCount); + InitialCreateExecution.PendingCompletionReceiptCount, + LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); } public void BindEventContext( @@ -1674,6 +1733,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); InitialCreateResidences.Clear(); InitialCreateExecution.DiscardAll(); + LocalPlayerFirstEntry.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index 636f1725..74e3fbb1 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -489,7 +489,7 @@ internal sealed class RuntimeInitialCreateResidenceState private readonly Dictionary _entries = []; private readonly Dictionary _completed = []; private Func? _generation; - private Action? _retirementNotification; + private readonly List> _retirementNotifications = []; private ulong _nextLeaseId; internal RuntimeInitialCreateResidenceState( @@ -524,17 +524,36 @@ internal sealed class RuntimeInitialCreateResidenceState /// leave the executor's progress AND its separately-tracked pending /// continuation placement token orphaned - this class owns no reference /// to the executor type, so the lifetime binds a plain delegate here - /// instead. + /// instead. Multicast (ordered invocation list, registration order) so a + /// second independent per-key owner (e.g. the local-player first-entry + /// conductor) can subscribe to the SAME retirements the executor already + /// does, without either overwriting the other's binding. /// internal void BindRetirementNotification(Action notify) { ArgumentNullException.ThrowIfNull(notify); - if (_retirementNotification is not null) - { - throw new InvalidOperationException( - "The initial Create residence retirement notification is already bound."); - } - _retirementNotification = notify; + _retirementNotifications.Add(notify); + } + + /// + /// H1: snapshots the subscriber list before invoking anything, mirroring + /// 's own copy-on-write + /// dispatch precedent. A subscriber binding a NEW notification from + /// inside a retirement callback it is itself receiving (e.g. a future + /// third, runtime-bound subscriber added at C3c) must not corrupt or be + /// skipped by THIS iteration - _retirementNotifications is a + /// plain , so iterating it directly while + /// appends to it mid-loop would + /// throw ("Collection was + /// modified"). ToArray() is the right granularity here (unlike + /// the event stream's -guarded array swap) because + /// binding only ever happens a handful of times at construction, never + /// on a hot per-frame path. + /// + private void NotifyRetirement(RuntimeEntityKey key) + { + foreach (Action notify in _retirementNotifications.ToArray()) + notify(key); } internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming) @@ -1003,7 +1022,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = entry.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); return true; } if (record.Key is { } completedKey @@ -1016,7 +1035,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = completed.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); - _retirementNotification?.Invoke(completedKey); + NotifyRetirement(completedKey); return true; } lease = default; @@ -1052,13 +1071,10 @@ internal sealed class RuntimeInitialCreateResidenceState { _setPosition.PublishCancellation(cancellations[index]); } - if (_retirementNotification is { } notify) - { - foreach (Entry entry in active) - notify(entry.Lease.Token.Entity); - foreach (CompletedEntry entry in completed) - notify(entry.Receipt.Token.Entity); - } + foreach (Entry entry in active) + NotifyRetirement(entry.Lease.Token.Entity); + foreach (CompletedEntry entry in completed) + NotifyRetirement(entry.Receipt.Token.Entity); } internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() => @@ -1245,7 +1261,7 @@ internal sealed class RuntimeInitialCreateResidenceState RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); } private void Retire(CompletedEntry entry) @@ -1255,6 +1271,6 @@ internal sealed class RuntimeInitialCreateResidenceState RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); - _retirementNotification?.Invoke(key); + NotifyRetirement(key); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs new file mode 100644 index 00000000..c5629e5d --- /dev/null +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFirstEntryState.cs @@ -0,0 +1,690 @@ +using AcDream.Content; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Gameplay; + +/// +/// Typed yields for . +/// Mirrors the executor's own RuntimeInitialCreateExecutionStatus +/// shape (terminal Completed/RejectedToken/RejectedAuthority plus named +/// retryable yields) rather than inventing a parallel vocabulary. +/// +internal enum RuntimeLocalPlayerFirstEntryStatus : byte +{ + /// + /// The underlying + /// call reported Completed: residence consumed, initial tail and + /// FIFO drained, ExecutorCompleted receipt dispatched on the placement + /// stream. Terminal; the conductor's own progress entry is removed. + /// + Completed, + + /// + /// The authored-mover Setup read + /// () is not + /// yet available (RetrySetupUnavailable) — the prepared-asset + /// package has not landed. Retry + /// with the same arguments once it has; no Runtime state changed. + /// + AwaitingCollisionSource, + + /// + /// + /// or + /// yielded DeferredCell or a retryable RejectedPlacement — + /// the destination cell's collision generation is not ready, or the + /// placement needs re-evaluation after some other change. Retry later + /// (e.g. on a collision-generation wake); the dormant activation lease + /// itself remains intact and is re-driven from the same stage. + /// + AwaitingActivation, + + /// + /// The activation committed but its Place projection has not been + /// acknowledged yet — either + /// was not (yet) the exact FIFO head, or the subsequent + /// call + /// still observed PendingPlacement from + /// . Retry the + /// same stage. + /// + AwaitingReceiptAcknowledgement, + + /// + /// Passthrough of the executor's own AwaitingContinuationPlacement + /// — a later FIFO continuation (a Position update accepted while this + /// entity's initial placement was in flight) needs its own authored + /// placement prepared/submitted/acknowledged before the drain can + /// finish. Entirely the executor's own concern from this point forward; + /// the conductor's job (residence -> publication -> Execute) is done as + /// soon as it reaches this yield. + /// + AwaitingContinuationPlacement, + + /// + /// A reentrant + /// call for the SAME entity arrived while an outer call for it was still + /// on the stack (mirrors the executor's own _executing fail-closed + /// guard). Not a Runtime-state rejection — retry once the outer call has + /// returned. + /// + Contention, + + /// The residence/placement token no longer matches anything tracked. + RejectedToken, + + /// + /// An authority-shaped failure (stale epoch/session/identity, deleted or + /// replaced record, GUID reuse, disposed identity, or an inner + /// currency check failing during a reentrant callback). Abandoned: the + /// conductor's own in-flight publication candidate/activation (if any) + /// is discarded through the shared choke points and its progress entry + /// is removed. The caller must begin a fresh first-entry sequence + /// (a new residence lease) rather than retry this exact call. + /// + RejectedAuthority, +} + +internal readonly record struct RuntimeLocalPlayerFirstEntryOwnershipSnapshot( + int ActiveCount) +{ + internal bool IsConverged => ActiveCount == 0; +} + +/// +/// The dormant, resumable Runtime transaction that dissolves C3's Finding B: +/// the local player's initial residence lease opens its SetPosition operation +/// at Create time, but nothing wires the mover-preparation -> +/// body/controller +/// attach -> placement acknowledgement -> FIFO drain sequence together into +/// one driveable state machine. This class ORCHESTRATES the existing, +/// already-tested residence +/// (), publication +/// (), mover +/// (), and +/// executor () +/// machinery — it reimplements none of their validation and bypasses none of +/// their staged semantics. +/// +/// Required order (campaign handoff route-1, +/// docs/research/2026-07-31-remaining-physics-campaign-handoff.md:280-292): +/// residence Begin (already done at registration, before this class is ever +/// invoked) -> authored-mover preparation (Setup read + PrepareMover, which +/// MUST precede publication Prepare — +/// 's own +/// CanPrepare gate requires +/// to +/// already be true) -> publication Prepare -> publication Commit (the +/// +/// seam attaches the body) -> activation Evaluate/Commit/Finalize -> +/// Place-receipt acknowledgement -> +/// (FIFO drain) -> ExecutorCompleted receipt. +/// +/// Once +/// sets an operation's DormantLocalActivation flag, +/// (and the +/// fused ) +/// must never be called against it again — see RetryDeferred's "must +/// never bypass that path through the ordinary remote CommitCanonical tail" +/// comment — so this class calls the mover-only +/// half instead +/// and never the fused method. +/// +/// Dormant by design: fully +/// constructs and wires this class (construction, publication binding, +/// retirement fan-out, bulk session-clear cleanup, ownership fold) exactly +/// like every other owner it builds, but nothing calls +/// in production — a later slice wires a host to drive +/// it. +/// +internal sealed class RuntimeLocalPlayerFirstEntryState +{ + private enum Stage : byte + { + /// No progress yet, or the mover has not been prepared. + AwaitingMoverPreparation, + + /// Mover command in hand; publication Prepare+Commit not run yet. + MoverPrepared, + + /// + /// Publication Prepare+Commit succeeded (the body/controller are + /// attached to the canonical record); Evaluate+CommitActivation not + /// yet reached Committed. Also the retry point for + /// DeferredCell/RejectedPlacement. + /// + PublicationCommitted, + + /// + /// CommitActivation reached Committed; the Place projection is + /// known but not yet acknowledged. + /// + ActivationCommitted, + + /// + /// The Place projection has been acknowledged. Only + /// + /// remains; re-acknowledging the same (already-consumed) token would + /// fail, so this stage is never re-entered by the acknowledgement + /// step. + /// + Acknowledged, + } + + private sealed class Progress + { + internal required ulong LeaseId { get; init; } + internal Stage Stage { get; set; } = Stage.AwaitingMoverPreparation; + internal RuntimeSetPositionCommand PreparedCommand { get; set; } + internal RuntimeLocalPlayerPhysicsPublicationToken PublicationToken + { get; set; } + internal RuntimeLocalPlayerPhysicsActivationToken ActivationToken + { get; set; } + internal RuntimePlacementProjectionToken Projection { get; set; } + } + + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimeInitialCreateContinuationExecutor _executor; + private readonly RuntimePhysicsState _physics; + private RuntimeLocalPlayerPhysicsPublicationState? _publication; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + + /// + /// F2: constructs this class + /// (alongside the residence and executor it also owns) BEFORE + /// exists — + /// GameRuntime creates RuntimeLocalPlayerMovementState and + /// attaches its physics publication only after the entity-object lifetime + /// is already built. This mirrors the SAME late-bind pattern already + /// used throughout this class family (BindGeneration, + /// BindRetirementNotification, BindLiveInputs, + /// RuntimeLocalPlayerMovementState.PhysicsPublication's own + /// throws-if-unbound accessor) rather than requiring the caller to + /// construct things out of their natural order. + /// + internal RuntimeLocalPlayerFirstEntryState( + RuntimeInitialCreateResidenceState residences, + RuntimeInitialCreateContinuationExecutor executor, + RuntimePhysicsState physics) + { + _residences = residences + ?? throw new ArgumentNullException(nameof(residences)); + _executor = executor + ?? throw new ArgumentNullException(nameof(executor)); + _physics = physics + ?? throw new ArgumentNullException(nameof(physics)); + } + + internal void BindPublication( + RuntimeLocalPlayerPhysicsPublicationState publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (_publication is not null) + { + throw new InvalidOperationException( + "The local-player first-entry conductor's publication owner is already bound."); + } + _publication = publication; + } + + private RuntimeLocalPlayerPhysicsPublicationState Publication => + _publication ?? throw new InvalidOperationException( + "The local-player first-entry conductor's publication owner is not yet bound."); + + /// + /// One resumable step. Callers pass the SAME arguments on every retry; + /// this method re-reads currency from the owning states on every entry + /// rather than trusting anything cached beyond its own stage cursor and + /// the exact token/receipt/projection structs each owning method itself + /// requires as arguments — there is no other source for those; they are + /// the "exact keys" this class carries, not a second copy of any owning + /// state's internal record. + /// + internal RuntimeLocalPlayerFirstEntryStatus Advance( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + PlayerMovementConstructionOptions options, + in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + out RuntimeInitialCreateExecutionReceipt receipt) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(collisionSource); + receipt = default; + if (!residenceToken.IsValid || record.Key is not { } key) + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + + // Mirrors RuntimeInitialCreateContinuationExecutor.Execute's own + // _executing.Add(key) guard: a synchronous reentrant call for the + // SAME entity (e.g. from a collision-report/placement observer + // invoked mid-Advance) fails closed rather than interleaving two + // drains of the same stage machine. + if (!_executing.Add(key)) + return RuntimeLocalPlayerFirstEntryStatus.Contention; + try + { + return AdvanceCore( + record, + residenceToken, + options, + activationPreparation, + collisionSource, + gameTime, + inputs, + key, + out receipt); + } + finally + { + _executing.Remove(key); + } + } + + private RuntimeLocalPlayerFirstEntryStatus AdvanceCore( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + PlayerMovementConstructionOptions options, + in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation, + IPreparedCollisionSource collisionSource, + double gameTime, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt) + { + receipt = default; + + // H2: fail transactionally, before any state mutation, if + // Publication has not been bound yet. Without this upfront check, + // an unbound call could still get as far as authored-mover + // preparation (which mutates RuntimeSetPositionState's own + // _preparedMovers) and creating THIS class's own Progress entry + // (stored into _progress) before the first Publication dereference + // (inside the MoverPrepared stage below) throws — leaving a + // poisoned Progress entry that a later, unrelated Discard/DiscardAll + // call (from a retirement notification or session-clear fan-out) + // would ALSO throw on. Referencing the accessor here throws + // immediately with nothing yet mutated. + _ = Publication; + + _progress.TryGetValue(key, out Progress? progress); + // ABA/GUID-reuse guard, exactly like the executor's own Progress + // reconciliation: an existing entry for a DIFFERENT (older or + // reused) lease id can never be resumed by this call. + if (progress is not null && progress.LeaseId != residenceToken.LeaseId) + { + Discard(key); + progress = null; + } + + if (progress is null || progress.Stage is Stage.AwaitingMoverPreparation) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + // No progress was ever tracked for this key under this exact + // lease — mirrors RuntimeInitialCreateResidenceState.Complete's + // own convention (a token matching neither its active nor its + // completed table is RejectedToken, not RejectedAuthority). + // Only abandon (RejectedAuthority) when THIS class was + // actually tracking in-flight publication/activation state + // that must now be discarded. + if (progress is null) + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + if (!lease.Route.PerformsSetPosition) + { + // Parented/PickedUp residence — never true for a real + // login, but kept for structural completeness: no + // SetPosition operation exists at all, so there is nothing + // for the publication chain to attach a body to. Skip + // straight to Execute (idempotent/retryable on its own). + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.Stage = Stage.Acknowledged; + _progress[key] = progress; + return RunExecute(record, residenceToken, inputs, key, out receipt); + } + + RuntimeSetPositionMoverPreparationStatus moverStatus = _physics + .SetPosition.TryPrepareAuthoredMover( + record, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command); + if (moverStatus + == RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable) + { + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingCollisionSource; + } + if (moverStatus != RuntimeSetPositionMoverPreparationStatus.Prepared) + { + if (progress is not null) + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress ??= new Progress { LeaseId = residenceToken.LeaseId }; + progress.PreparedCommand = command; + progress.Stage = Stage.MoverPrepared; + _progress[key] = progress; + } + + if (progress.Stage is Stage.MoverPrepared) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + RuntimeLocalPlayerPhysicsPublicationStatus prepareStatus = + Publication.Prepare( + record, + lease.Placement, + progress.PreparedCommand, + options, + activationPreparation, + out RuntimeLocalPlayerPhysicsPublicationToken pubToken); + if (prepareStatus + != RuntimeLocalPlayerPhysicsPublicationStatus.Prepared) + { + // Prepare mutates nothing canonical on rejection (its own + // second CanPrepare recheck discards any just-built + // candidate itself); there is nothing further for this + // class to undo beyond dropping its own progress entry. + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress.PublicationToken = pubToken; + + RuntimeLocalPlayerPhysicsPublicationStatus commitStatus = + Publication.Commit( + pubToken, + out RuntimeLocalPlayerPhysicsActivationToken activationToken); + if (commitStatus != RuntimeLocalPlayerPhysicsPublicationStatus.Committed) + { + Discard(key); + return commitStatus + is RuntimeLocalPlayerPhysicsPublicationStatus.RejectedToken + ? RuntimeLocalPlayerFirstEntryStatus.RejectedToken + : RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + + progress.ActivationToken = activationToken; + progress.Stage = Stage.PublicationCommitted; + } + + if (progress.Stage is Stage.PublicationCommitted) + { + // A single combined retry point for Evaluate+CommitActivation. + // Every publication test that hits DeferredCell/RejectedPlacement + // WITHOUT an intervening CommitActivation call chains both calls + // together and retries both together; running EvaluateActivation + // again before every CommitActivation retry is safe even for + // CommitActivation's own internal AwaitingFinalShadowPreparation + // resumption (its top-of-method check re-validates + // activation.Receipt == receipt, which a fresh Evaluate call + // satisfies, before consulting the untouched stored + // PendingFinalCommit). + // + // EvaluateActivation's DeferredCell status is overloaded: once a + // PRIOR CommitActivation call has already registered this lease + // as awaiting a specific cell/collision generation + // (IsDormantLocalActivationAwaitingCell), a REPEATED + // EvaluateActivation call that is still not ready returns + // DeferredCell WITHOUT ever populating receipt (it stays + // default/invalid) — see + // DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake + // in the publication test suite, which asserts exactly + // `waiting.IsValid == false` on that repeat call and never feeds + // it to CommitActivation. Calling CommitActivation with that + // invalid receipt would hit its own `!receipt.IsValid` guard and + // incorrectly report RejectedAuthority instead of "still + // waiting" — so this class must check validity first and simply + // yield AwaitingActivation again without calling CommitActivation + // at all in that case. + RuntimeLocalPlayerPhysicsActivationStatus evalStatus = + Publication.EvaluateActivation( + progress.ActivationToken, + out RuntimeLocalPlayerPhysicsActivationReceipt evalReceipt); + if (evalStatus is + RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken + or RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority) + { + Discard(key); + return evalStatus + is RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken + ? RuntimeLocalPlayerFirstEntryStatus.RejectedToken + : RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + if (!evalReceipt.IsValid) + { + // DeferredCell with nothing to commit — the destination + // cell/collision generation genuinely is not resolvable yet. + return RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation; + } + + RuntimeDormantSetPositionCommitStatus commitActivationStatus = + Publication.CommitActivation( + evalReceipt, + out RuntimePlacementProjectionToken projection); + switch (commitActivationStatus) + { + case RuntimeDormantSetPositionCommitStatus.Committed: + progress.Projection = projection; + progress.Stage = Stage.ActivationCommitted; + break; + case RuntimeDormantSetPositionCommitStatus.DeferredCell: + case RuntimeDormantSetPositionCommitStatus.RejectedPlacement: + // Stage stays PublicationCommitted — retry re-runs both + // EvaluateActivation and CommitActivation next Advance. + return RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation; + default: + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + } + + if (progress.Stage is Stage.ActivationCommitted) + { + if (!_physics.SetPosition.AcknowledgeProjection( + progress.Projection)) + { + // A failed acknowledge is retryable ONLY while nothing has + // moved authority out from under this exact projection — a + // genuinely later entity simply sitting ahead of ours in the + // FIFO. It is NOT automatically retryable: a mid-flight + // delete (TryAcceptDelete -> CompleteProjectionRetirement -> + // Physics.SetPosition.Forget -> CancelCoreDeferred) rewrites + // the SAME pending slot from Place to Discard with a bumped + // Revision, so the exact struct this class cached in + // progress.Projection can never match the FIFO head again — + // without this check, AcknowledgeProjection would fail + // forever and this progress entry would never converge. + if (!IsAcknowledgementStillPending( + record, residenceToken, progress.Projection)) + { + Discard(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingReceiptAcknowledgement; + } + progress.Stage = Stage.Acknowledged; + } + + return RunExecute(record, residenceToken, inputs, key, out receipt); + } + + /// + /// Re-validates authority after a failed acknowledge. Two independent + /// checks, either of which failing means authority moved and this class + /// must abandon rather than keep retrying forever: (1) the residence + /// lease this whole sequence began under must still be exactly current + /// (mirrors the stage0/stage1 checks — a delete or reset retires it); + /// (2) if the FIFO head belongs to THIS entity at all, it must still be + /// the exact Place projection this class is holding — a head that + /// belongs to us but is no longer that exact token (rewritten to + /// Discard, or to a later revision) means our specific placement was + /// superseded even if the residence lookup transiently still resolves. + /// A head belonging to a DIFFERENT entity is the genuine "not yet our + /// turn" case and must stay retryable. This reads the Runtime-internal + /// directly + /// rather than through the public, generation-gated + /// — this class is part + /// of Runtime, not an external host crossing that boundary, exactly like + /// its existing direct + /// call above. + /// + private bool IsAcknowledgementStillPending( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimePlacementProjectionToken expected) + { + if (!_residences.TryGetCurrent( + record, + out RuntimeInitialCreateResidenceLease lease) + || lease.Token != residenceToken) + { + return false; + } + + if (_physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head) + && head.Token.Entity == expected.Entity + && (head.Kind is not RuntimePlacementProjectionKind.Place + || head.Token != expected)) + { + return false; + } + + return true; + } + + private RuntimeLocalPlayerFirstEntryStatus RunExecute( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken residenceToken, + in RuntimeInitialCreateExecutionInputs inputs, + RuntimeEntityKey key, + out RuntimeInitialCreateExecutionReceipt receipt) + { + RuntimeInitialCreateExecutionStatus executeStatus = _executor.Execute( + record, residenceToken, inputs, out receipt); + switch (executeStatus) + { + case RuntimeInitialCreateExecutionStatus.Completed: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.Completed; + case RuntimeInitialCreateExecutionStatus.PendingPlacement: + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingReceiptAcknowledgement; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + return RuntimeLocalPlayerFirstEntryStatus + .AwaitingContinuationPlacement; + case RuntimeInitialCreateExecutionStatus.RejectedToken: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedToken; + default: + _progress.Remove(key); + return RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority; + } + } + + /// + /// Discards any in-flight publication candidate/activation this class + /// owns for and drops its own progress entry. + /// Safe to call at every stage: + /// and + /// are both no-ops against a default/invalid token (an unreached stage's + /// token field holds exactly that) AND against a token whose activation + /// has already reached FinalizeActivation's terminal success path + /// — FinalizeActivation itself nulls the publication's own + /// tracked activation the instant the commit succeeds, because the + /// controller is now genuinely live/published, not a discardable + /// in-progress candidate. Calling this once + /// (or later) has been reached is therefore correctly a no-op on the + /// controller/body — an abandoned Place acknowledgement never + /// retroactively un-publishes an already-live entity; that is ordinary + /// entity teardown's job (the SAME path any other live entity's delete + /// already runs), not this class's. The residence and executor own + /// their own convergence independently (their existing retirement/reset + /// paths are untouched by this class). + /// + private void Discard(RuntimeEntityKey key) + { + if (!_progress.TryGetValue(key, out Progress? progress)) + return; + _progress.Remove(key); + // H2: post-H2, a Progress entry can only exist at all if Advance's + // own upfront check already found Publication bound — this should + // therefore be structurally unreachable. Guarded anyway + // (belt-and-suspenders) so a future caller shape can never turn an + // already-surfaced Advance failure into a SECOND throw from inside + // an unrelated retirement/session-clear teardown fan-out. + if (_publication is null) + return; + Publication.Discard(progress.PublicationToken); + Publication.DiscardActivation(progress.ActivationToken); + } + + /// + /// Cleanup for one key this class is tracking. + /// binds this into 's + /// multicast retirement notification (alongside the executor's own + /// DiscardProgress), so any residence retirement path — delete, + /// reset, generation replacement, a host discovering staleness — reaps + /// this class's progress automatically. The notification always carries + /// the exact the residence itself tracked + /// internally, so it converges correctly even after + /// has gone null (e.g. post-delete + /// teardown released the local id) — unlike re-deriving a key from the + /// record, which cannot do once that happens. + /// Still exposed directly for a caller that captured a key before a + /// teardown this class was not notified about (e.g. constructed + /// standalone in a test without the lifetime's fan-out). + /// + internal void Forget(RuntimeEntityKey key) => Discard(key); + + /// + /// Bulk cleanup mirroring + /// — wired into the same session-clear sequence + /// (). Discards + /// every tracked key's in-flight publication candidate/activation before + /// dropping the whole progress table, exactly like a per-key + /// for each entry. + /// + internal void DiscardAll() + { + // H2: same belt-and-suspenders tolerance as Discard above — a + // structurally unreachable case post-H2, guarded so bulk session + // clear can never throw from an unbound Publication either. + if (_publication is not null) + { + foreach (Progress progress in _progress.Values) + { + Publication.Discard(progress.PublicationToken); + Publication.DiscardActivation(progress.ActivationToken); + } + } + _progress.Clear(); + } + + internal RuntimeLocalPlayerFirstEntryOwnershipSnapshot CaptureOwnership() => + new(_progress.Count); +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 7a285b24..263c8f5b 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -1576,11 +1576,69 @@ internal sealed class RuntimeSetPositionState : IDisposable uint scatterAttempts = 0u, float shadowWorldOffsetX = 0f, float shadowWorldOffsetY = 0f) + { + outcome = default; + + RuntimeSetPositionMoverPreparationStatus status = + TryPrepareAuthoredMover( + record, + token, + operationKind, + flags, + collisionSource, + gameTime, + out RuntimeSetPositionCommand command, + placementClass, + portal, + line, + scatterRadiusX, + scatterRadiusY, + scatterAttempts, + shadowWorldOffsetX, + shadowWorldOffsetY); + if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) + return status; + + outcome = SubmitPreparedPlacement(token, command); + return RuntimeSetPositionMoverPreparationStatus.Prepared; + } + + /// + /// C3a: the exact-Setup-read + half of + /// , extracted (pure + /// refactor, byte-identical behavior for the existing caller above) so + /// the dormant local-player first-entry conductor + /// (AcDream.Runtime.Gameplay.RuntimeLocalPlayerFirstEntryState) + /// can obtain a prepared WITHOUT + /// the trailing call. Submission + /// through the ordinary tail is exactly what an operation with + /// DormantLocalActivation set must never receive — see + /// 's "must never bypass that path through the + /// ordinary remote CommitCanonical tail" comment; the dormant chain's own + /// Evaluate/Commit/FinalizeActivation methods are the sole substitute for + /// submission on that path. + /// + internal RuntimeSetPositionMoverPreparationStatus TryPrepareAuthoredMover( + RuntimeEntityRecord record, + in RuntimeEntityPlacementToken token, + RuntimeSetPositionOperationKind operationKind, + PhysicsSetPositionFlags flags, + IPreparedCollisionSource collisionSource, + double gameTime, + out RuntimeSetPositionCommand command, + PhysicsPlacementClass placementClass = PhysicsPlacementClass.Ordinary, + RuntimePortalPlacementAuthority portal = default, + Vector3 line = default, + float scatterRadiusX = 0f, + float scatterRadiusY = 0f, + uint scatterAttempts = 0u, + float shadowWorldOffsetX = 0f, + float shadowWorldOffsetY = 0f) { EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(collisionSource); - outcome = default; + command = default; uint setupTableId = CanonicalSetupTableId(record); RuntimeSetPositionMoverSetup setup; @@ -1615,13 +1673,7 @@ internal sealed class RuntimeSetPositionState : IDisposable shadowWorldOffsetX, shadowWorldOffsetY, portal); - RuntimeSetPositionMoverPreparationStatus status = PrepareMover( - token, preparation, out RuntimeSetPositionCommand command); - if (status != RuntimeSetPositionMoverPreparationStatus.Prepared) - return status; - - outcome = SubmitPreparedPlacement(token, command); - return RuntimeSetPositionMoverPreparationStatus.Prepared; + return PrepareMover(token, preparation, out command); } internal bool IsExactPreparedPlacementCurrent( diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index 57ed44e8..e5344af5 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -2623,6 +2623,53 @@ public sealed class RuntimeInitialCreateResidenceStateTests lifetime.Entities.SetPhysicsBody(canonical, body); } + [Fact] + public void RetirementNotificationBoundReentrantlyDuringDispatchDoesNotCorruptTheCurrentIteration() + { + // H1: RuntimeEntityObjectLifetime's own constructor already binds + // TWO retirement notifications (the executor's DiscardProgress and + // the local-player first-entry conductor's Forget). Before this + // fix, NotifyRetirement iterated the live _retirementNotifications + // list directly; a THIRD subscriber binding from inside one of + // those two (or this test's own) callbacks would throw + // "Collection was modified" on the very next notify() call in the + // SAME foreach. Snapshotting via ToArray() before iterating must + // let a reentrant bind land safely mid-dispatch, with the newly + // added subscriber observed on the NEXT retirement, not the current + // one (it is not required to see notifications that were already + // "in flight" when it bound). + using var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 20UL); + var lateBoundKeys = new List(); + bool reentrantBindAttempted = false; + lifetime.InitialCreateResidences.BindRetirementNotification(key => + { + if (reentrantBindAttempted) + return; + reentrantBindAttempted = true; + lifetime.InitialCreateResidences.BindRetirementNotification( + lateBoundKeys.Add); + }); + + RuntimeEntityRecord first = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003F80u, 1), isLocalPlayer: false).Canonical!; + RuntimeEntityKey firstKey = first.Key!.Value; + Exception? thrown = Record.Exception(() => + lifetime.InitialCreateResidences.Forget(first, out _, out _)); + + Assert.Null(thrown); + Assert.True(reentrantBindAttempted); + Assert.DoesNotContain(firstKey, lateBoundKeys); + + RuntimeEntityRecord second = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70003F81u, 1), isLocalPlayer: false).Canonical!; + RuntimeEntityKey secondKey = second.Key!.Value; + Assert.True(lifetime.InitialCreateResidences.Forget( + second, out _, out _)); + + Assert.Contains(secondKey, lateBoundKeys); + } + private static void Bind( RuntimeEntityObjectLifetime lifetime, ulong generation) diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs new file mode 100644 index 00000000..07ae7bd7 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs @@ -0,0 +1,922 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime; + +namespace AcDream.Runtime.Tests.Gameplay; + +public sealed class RuntimeLocalPlayerFirstEntryStateTests +{ + private const uint Landblock = 0xA9B60000u; + private const uint Cell = Landblock | 0x0001u; + private const uint SetupId = 0x02000001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------- + + [Fact] + public void FullSequenceHappyPathReachesExecutorCompletedWithOneBodyIdentity() + { + using var fixture = new Fixture(residentWorld: true); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, + receipt.TeleportHookPhase); + PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody); + Assert.True(body.InWorld); + PlayerMovementController controller = + Assert.IsType(fixture.Movement.Controller); + Assert.Same(body, controller.PhysicsBody); + Assert.True(controller.IsRuntimePublished); + Assert.NotNull(fixture.Record.PhysicsHost); + Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.False(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateResidenceLeaseCount); + Assert.Equal(0, fixture.Lifetime.CaptureOwnership() + .InitialCreateExecutorProgressCount); + + // Retrying with the now-stale residence token is a distinct, + // safe no-op — nothing left to resume. + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + // --------------------------------------------------------------- + // Yield flavors + resume + // --------------------------------------------------------------- + + [Fact] + public void AwaitingCollisionSourceRetriesThenResumesOnceSetupLands() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + first); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + second); + Assert.True(fixture.CollisionSource.ReadCount >= 2); + + fixture.CollisionSource.Status = PreparedAssetReadStatus.Loaded; + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + } + + [Fact] + public void AwaitingActivationRetriesWhileCellUnresolvedThenResumesAfterGenerationWake() + { + // Non-resident world: EvaluateActivation defers because the + // destination landblock's collision generation was never committed. + using var fixture = new Fixture(residentWorld: false); + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, first); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.False(fixture.Record.PhysicsBody!.InWorld); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, second); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + } + + [Fact] + public void AwaitingReceiptAcknowledgementRetriesWhileNotFifoHeadThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // Occupy the FIFO head with an unrelated, unacknowledged Place + // projection before our own activation ever commits, so our own + // token — assigned a LATER sequence — cannot acknowledge first. + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099001u); + + RuntimeLocalPlayerFirstEntryStatus first = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + first); + // The activation itself already committed — retrying must not + // re-run CommitActivation (which would reject a second time and + // corrupt the ledger); only the acknowledgement itself is retried. + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + + RuntimeLocalPlayerFirstEntryStatus second = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + second); + + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, + fixture.Advance(out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(Cell, receipt.FullCellId); + _ = other; + } + + [Fact] + public void DeleteWhileAwaitingReceiptAcknowledgementAbandonsInsteadOfRetryingForeverAndConverges() + { + // F1 regression: the gap between the two original delete tests (one + // before activation ever commits, one after a non-resident + // DeferredCell). Here the activation has ALREADY committed — the + // projection is captured and real — but acknowledgement is blocked + // behind another entity's own unacknowledged Place. A full delete + // ALSO retires the residence (TryAcceptDelete's own + // ForgetInitialCreateResidence call), which — now that F2 wires this + // class's Forget into that same retirement notification — converges + // everything automatically before Advance is ever called again; + // that path is covered by the delete-while-AwaitingActivation and + // reincarnation tests instead. This test exercises the narrower + // mechanism directly: Physics.SetPosition.Forget alone (the exact + // call TryAcceptDelete itself makes, releasePreparedMover: true) + // rewrites the still-pending Place slot straight to Discard with a + // bumped revision (CancelCoreDeferred) — invalidating BOTH the + // residence's own placement-tracked currency AND the FIFO head's + // projection kind/token for this entity. Without + // IsAcknowledgementStillPending's re-check on a failed acknowledge, + // the cached progress.Projection struct could never match the FIFO + // head again and retrying Advance would report + // AwaitingReceiptAcknowledgement forever, with Progress/Publication + // ownership never converging. + using var fixture = new Fixture(residentWorld: true); + (RuntimeEntityRecord other, RuntimePlacementProjectionToken otherToken) = + BeginPendingOrdinaryPlacement(fixture, 0x70099002u); + + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingReceiptAcknowledgement, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.True(fixture.Record.PhysicsBody!.InWorld); + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + fixture.Record, out _)); + + RuntimePlacementCancellationReceipt cancellation = fixture.Lifetime + .Physics.SetPosition.Forget( + fixture.Record, + releasePreparedMover: true); + fixture.Lifetime.Physics.SetPosition.PublishCancellation(cancellation); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + // FinalizeActivation already nulled Publication's own tracked + // activation the instant CommitActivation succeeded (the controller + // is genuinely live/published from that point on, not an in-progress + // candidate) — so PendingActivationCount was already 0 before this + // abandonment ran, and stays 0. Abandoning the acknowledgement here + // does not retroactively un-publish the now-live controller; that is + // ordinary entity teardown's job, not this class's. + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.NotNull(fixture.Movement.Controller); + Assert.True(fixture.Movement.Controller!.IsRuntimePublished); + + // The unrelated entity's own placement is untouched and still + // acknowledgeable — this class's abandonment must not have reached + // past its own entity's projection. + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(otherToken)); + _ = other; + } + + [Fact] + public void AwaitingContinuationPlacementPropagatesExecutorYieldThenResumes() + { + using var fixture = new Fixture(residentWorld: true); + // A fresh Position arrives while the initial residence is still + // pending — exactly the scenario the executor's own FIFO exists + // for. It is enqueued as a continuation and only classified once + // the executor actually drains it. + WorldSession.EntityPositionUpdate update = new( + fixture.Record.ServerGuid, + new CreateObject.ServerPosition(Cell, 40f, 20f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 2, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 1, + ForcePositionSequence: 0); + Assert.True(fixture.Lifetime.TryApplyPosition( + update, + isLocalPlayer: true, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: true, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(out _); + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingContinuationPlacement, + status); + // The conductor's OWN sequence (residence -> publication -> Execute) + // already reached its terminal Acknowledged stage; its progress + // entry is retained (not zero) purely so a retry skips straight to + // re-calling Execute rather than restarting mover-prep/publication + // from scratch — discarding it here would incorrectly reject a + // retry with RejectedAuthority once the original residence lease is + // long gone. + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + + RuntimeEntityKey key = fixture.Record.Key!.Value; + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement( + key, out RuntimeEntityPlacementToken placement)); + Assert.True(fixture.Lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute( + key, out RuntimeAuthoritativePositionRoute route)); + CompleteOrdinaryPlacement(fixture, placement, route); + + RuntimeLocalPlayerFirstEntryStatus resumed = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, resumed); + Assert.Contains(receipt.Trace, + a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + [Fact] + public void ReentrantAdvanceDuringCollisionCallbackFailsClosedWithContentionAndOuterCallStillCompletes() + { + using var fixture = new Fixture(residentWorld: true); + bool reentered = false; + RuntimeLocalPlayerFirstEntryStatus? innerStatus = null; + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (_, phase, _, observed) => + { + if (!reentered && phase is TransitionCellCollisionPhase.Environment) + { + reentered = true; + innerStatus = fixture.Advance(out _); + } + return observed; + }; + + RuntimeLocalPlayerFirstEntryStatus outerStatus = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.True(reentered); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Contention, innerStatus); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, outerStatus); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Publication binding (H2) + // --------------------------------------------------------------- + + [Fact] + public void AdvanceWithUnboundPublicationThrowsTransactionallyBeforeAnyStateMutation() + { + // H2: RuntimeEntityObjectLifetime constructs LocalPlayerFirstEntry + // before RuntimeLocalPlayerPhysicsPublicationState can exist, so an + // Advance in the window before a host calls BindPublication must + // fail with NOTHING mutated — no authored-mover Setup-read/ + // PrepareMover call against RuntimeSetPositionState's own + // _preparedMovers, no Progress entry created. Otherwise a poisoned + // Progress entry would sit in _progress forever, and a later + // Discard/DiscardAll from an unrelated retirement/session-clear + // fan-out would ALSO throw. + using var lifetime = new RuntimeEntityObjectLifetime(); + var generation = new RuntimeGenerationToken(1UL); + lifetime.BindEventContext(() => generation, static () => 1UL); + RuntimeEntityRecord record = lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70090099u, incarnation: 1), + isLocalPlayer: true).Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + record, out RuntimeInitialCreateResidenceLease lease)); + var collisionSource = new FakeCollisionSource( + 0u, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + Assert.Throws(() => + lifetime.LocalPlayerFirstEntry.Advance( + record, + lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + collisionSource, + gameTime: 10d, + NoContact, + out _)); + + Assert.Equal(0, lifetime.LocalPlayerFirstEntry.CaptureOwnership().ActiveCount); + // The residence lease is untouched — the SAME token still drives to + // completion once a host binds Publication, proving nothing was + // mutated by the failed attempt. + Assert.True(lifetime.TryGetInitialCreateResidence( + record, out RuntimeInitialCreateResidenceLease stillLease)); + Assert.Equal(lease.Token, stillLease.Token); + + var movement = new RuntimeLocalPlayerMovementState(); + var identity = new RuntimeLocalPlayerIdentityState(); + try + { + identity.ServerGuid = record.ServerGuid; + var publication = new RuntimeLocalPlayerPhysicsPublicationState( + lifetime.Entities, lifetime.Physics, movement, identity); + movement.AttachPhysicsPublication(publication); + lifetime.LocalPlayerFirstEntry.BindPublication(publication); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + lifetime.LocalPlayerFirstEntry.Advance( + record, + lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + 0.48f, 1.835f, RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + collisionSource, + gameTime: 10d, + NoContact, + out _)); + } + finally + { + // F2 disposal-ordering constraint: Lifetime's own Dispose runs + // BeginSessionClear -> LocalPlayerFirstEntry.DiscardAll, which + // needs Publication still alive. + lifetime.Dispose(); + movement.Dispose(); + identity.Dispose(); + } + } + + [Fact] + public void BindPublicationTwiceThrows() + { + using var fixture = new Fixture(residentWorld: false); + Assert.Throws( + () => fixture.Conductor.BindPublication(fixture.Publication)); + } + + // --------------------------------------------------------------- + // Retry idempotency + // --------------------------------------------------------------- + + [Fact] + public void RetryAtMoverPreparationNeverCreatesAPublicationCandidate() + { + using var fixture = new Fixture(residentWorld: true, setupTableId: SetupId); + fixture.CollisionSource.Status = PreparedAssetReadStatus.Missing; + + for (int i = 0; i < 3; i++) + { + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingCollisionSource, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Null(fixture.Record.PhysicsBody); + Assert.Null(fixture.Movement.Controller); + } + } + + [Fact] + public void RetryAtAwaitingActivationNeverRecommitsPublicationOrDuplicatesTheBody() + { + using var fixture = new Fixture(residentWorld: false); + PhysicsBody? body = null; + + for (int i = 0; i < 3; i++) + { + Assert.Equal( + RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + body ??= fixture.Record.PhysicsBody; + Assert.Same(body, fixture.Record.PhysicsBody); + Assert.Same(fixture.Movement.Controller, fixture.Movement.Controller); + } + } + + // --------------------------------------------------------------- + // Abandonment + converged ledgers + // --------------------------------------------------------------- + + [Fact] + public void DeleteMidFlightDuringActivationAbandonsWithoutShadowOrPlaceAndConverges() + { + // Mirrors the publication suite's own + // CollisionCallbackDeleteRetiresPrephaseWithoutShadowOrPlace: the + // delete lands DURING the same Advance call (from inside + // CommitActivation's collision dispatch), not across a retry + // boundary — the only place a genuine mid-flight abandonment (as + // opposed to a stale-lease retry) can be exercised for this fused + // Prepare->Commit->Evaluate->CommitActivation sequence. + using var fixture = new Fixture(residentWorld: true); + bool deleted = false; + var placements = new List(); + using IDisposable placementSubscription = fixture.Lifetime.Events + .SubscribePlacement(new PlacementObserver(d => placements.Add(d))); + fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = + (transition, phase, _, observed) => + { + if (deleted || phase is not TransitionCellCollisionPhase.Environment) + return observed; + deleted = true; + DeleteEntity(fixture); + return observed; + }; + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance(out _); + + Assert.True(deleted); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedAuthority, status); + Assert.DoesNotContain(placements, + d => d.Placement.Kind is RuntimePlacementProjectionKind.Place); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().CandidateCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + } + + [Fact] + public void DeleteWhileAwaitingActivationConvergesAutomaticallyThroughTheRetirementFanOut() + { + // RuntimeEntityRecord.Key is computed from LocalEntityId and goes + // null the instant ReleaseLocalId runs (part of delete's teardown, + // RuntimeEntityObjectLifetime.CompleteProjectionRetirement) — so a + // SUBSEQUENT Advance call can never recompute the dictionary key to + // reach its own stale progress (RejectedToken, mirroring the + // executor's own convention for "nothing addressable here" — same + // as calling Execute with a record whose Key just went null). F2: + // this no longer matters for cleanup, because TryAcceptDelete's own + // ForgetInitialCreateResidence call fires the residence's multicast + // retirement notification SYNCHRONOUSLY, DURING delete itself — + // RuntimeEntityObjectLifetime binds this class's Forget into that + // SAME fan-out (alongside the executor's DiscardProgress), using the + // exact key the notification carries, never one re-derived from + // record.Key. Convergence is therefore already complete by the time + // delete returns, with no separate host Forget call needed. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + DeleteEntity(fixture); + Assert.Null(fixture.Record.Key); + + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + Assert.Null(fixture.Movement.Controller); + + // Retrying Advance afterward is a safe, distinct no-op. + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + } + + [Fact] + public void MovementResetSessionMidFlightConvergesOwnershipThroughOrdinaryAdvance() + { + // Unlike delete, ResetSession does not touch RuntimeEntityRecord.Key + // at all — it only clears RuntimeLocalPlayerPhysicsPublicationState's + // own candidate/activation directly. So a plain retry of Advance (no + // captured-key Forget needed) reaches EvaluateActivation, which + // reports RejectedToken because _activation is now null outright + // (its own "nothing here" status, not "found but stale") — and this + // class's Discard(key) still runs on that path, converging ownership + // through ordinary retry alone. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + + fixture.Movement.ResetSession(); + + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.RejectedToken, + fixture.Advance(out _)); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + } + + // --------------------------------------------------------------- + // GUID / LeaseId staleness + // --------------------------------------------------------------- + + [Fact] + public void DeleteAndSameGuidReincarnationAutomaticallyFreesThePublicationSlotForTheFreshIncarnation() + { + // Delete releases the old LocalEntityId (ReleaseLocalId), so a + // reincarnation's RuntimeEntityKey structurally differs from the + // deleted one — the orphaned incarnation-1 progress entry can never + // collide with the fresh incarnation-2 dictionary slot. Separately, + // RuntimeLocalPlayerPhysicsPublicationState holds exactly ONE + // dormant candidate/activation globally (there is only ever one + // local player), so the fresh incarnation's own Prepare would be + // structurally rejected (CanPrepare requires the slot empty) for as + // long as the orphaned incarnation-1 activation still occupied it. + // F2: TryAcceptDelete's own ForgetInitialCreateResidence call fires + // the residence's multicast retirement notification synchronously, + // DURING delete — RuntimeEntityObjectLifetime binds this class's + // Forget into that SAME fan-out, so the stale activation is already + // discarded by the time delete returns. No separate host Forget is + // needed for the fresh incarnation to proceed immediately. + using var fixture = new Fixture(residentWorld: false); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + RuntimeEntityKey staleKey = fixture.Record.Key!.Value; + Assert.Equal(1, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(1, fixture.Publication.CaptureOwnership().PendingActivationCount); + + uint guid = fixture.Record.ServerGuid; + DeleteEntity(fixture); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + Assert.Equal(0, fixture.Publication.CaptureOwnership().PendingActivationCount); + RuntimeEntityRecord reincarnated = fixture.Lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, incarnation: 2), + isLocalPlayer: true) + .Canonical!; + // ReleaseLocalId (part of delete's teardown) returns the old + // LocalEntityId to the free pool rather than pinning it per GUID, so + // the reincarnation's key differs in BOTH fields, not just + // Incarnation — either way it is a different _progress dictionary + // key than the deleted incarnation's. + Assert.NotEqual(staleKey, reincarnated.Key!.Value); + fixture.Identity.ServerGuid = reincarnated.ServerGuid; + Assert.True(fixture.Lifetime.TryGetInitialCreateResidence( + reincarnated, + out RuntimeInitialCreateResidenceLease freshLease)); + fixture.Record = reincarnated; + fixture.Lease = freshLease; + + // The fresh incarnation's own publication Prepare succeeds + // immediately — the one global slot was already freed by delete's + // automatic convergence above, with no explicit Forget call in + // between. The fixture's non-resident world defers the fresh + // incarnation's own activation once — wake it the same way the + // AwaitingActivation retry test does. + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.AwaitingActivation, + fixture.Advance(out _)); + const ulong generation = 1UL; + fixture.Lifetime.Physics.SetPosition.BeginCollisionGeneration( + Cell & 0xFFFF0000u, generation); + fixture.Lifetime.Physics.Engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration( + Cell & 0xFFFF0000u, generation, ready: true); + + RuntimeLocalPlayerFirstEntryStatus status = fixture.Advance( + out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeLocalPlayerFirstEntryStatus.Completed, status); + Assert.Equal(Cell, receipt.FullCellId); + Assert.Same(reincarnated, fixture.Record); + Assert.Equal(reincarnated.Key!.Value.LocalEntityId, + fixture.Movement.Controller!.LocalEntityId); + Assert.Same(reincarnated.PhysicsBody, fixture.Movement.Controller.PhysicsBody); + Assert.Equal(0, fixture.Conductor.CaptureOwnership().ActiveCount); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static (RuntimeEntityRecord Record, RuntimePlacementProjectionToken Token) + BeginPendingOrdinaryPlacement(Fixture fixture, uint guid) + { + RuntimeEntityRecord record = fixture.Lifetime.RegisterEntity( + Spawn(guid, incarnation: 1, includePosition: true)).Canonical!; + var body = new PhysicsBody + { + Position = new Vector3(50f, 50f, 3f), + Orientation = Quaternion.Identity, + State = record.FinalPhysicsState, + }; + body.SnapToCell(Cell, body.Position, body.Position); + fixture.Lifetime.Entities.SetPhysicsBody(record, body); + RuntimeEntityPlacementToken placement = fixture.Lifetime.Physics + .SetPosition.BeginAuthoredPlacement( + record, + record.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(placement.IsValid); + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + PhysicsSetPositionFlags.Placement | PhysicsSetPositionFlags.Slide); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + return (record, outcome.Projection); + } + + private static void CompleteOrdinaryPlacement( + Fixture fixture, + in RuntimeEntityPlacementToken placement, + in RuntimeAuthoritativePositionRoute route) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + route.OperationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + route.SetPositionFlags); + Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, + fixture.Lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + RuntimeSetPositionOutcome outcome = fixture.Lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal(RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(fixture.Lifetime.Physics.SetPosition + .AcknowledgeProjection(outcome.Projection)); + } + + private static void DeleteEntity(Fixture fixture) + { + Assert.True(fixture.Lifetime.TryAcceptDelete( + new DeleteObject.Parsed(fixture.Record.ServerGuid, fixture.Record.Incarnation), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + fixture.Lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(fixture.Lifetime.RetireCanonicalOnly(fixture.Record)); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint setupTableId = 0u) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, 1f, 2f, 3f, 1f, 0f, 0f, 0f) + : null; + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + RawState: (uint)(PhysicsStateFlags.Gravity + | PhysicsStateFlags.ReportCollisions), + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: setupTableId == 0u ? null : setupTableId, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid: guid, + Position: position, + SetupTableId: setupTableId == 0u ? null : setupTableId, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "first-entry-fixture", + ItemType: null, + MotionState: null, + MotionTableId: 0x09000001u, + PhysicsState: physics.RawState, + ObjectDescriptionFlags: 0x8u, + Friction: null, + Elasticity: null, + InstanceSequence: incarnation, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class PlacementObserver(Action onPlacement) + : IRuntimePlacementObserver + { + public void OnPlacement(in RuntimePlacementDelta delta) => onPlacement(delta); + } + + private sealed class FakeCollisionSource( + uint expectedSetupTableId, + FlatSetupCollision setup) : IPreparedCollisionSource + { + internal int ReadCount { get; private set; } + internal PreparedAssetReadStatus Status { get; set; } = + PreparedAssetReadStatus.Loaded; + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + ReadCount++; + Assert.Equal(expectedSetupTableId, sourceFileId); + return Status switch + { + PreparedAssetReadStatus.Loaded => + PreparedCollisionReadResult.Loaded(setup), + PreparedAssetReadStatus.Corrupt => + PreparedCollisionReadResult.Corrupt, + _ => PreparedCollisionReadResult.Missing, + }; + } + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException( + "Only ReadSetupCollision is exercised by these tests."); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } + + private sealed class Fixture : IDisposable + { + internal Fixture(bool residentWorld, uint setupTableId = 0u) + { + if (residentWorld) + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Cell & 0xFFFF0000u, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + } + else + { + Lifetime = new RuntimeEntityObjectLifetime(); + } + var generation = new RuntimeGenerationToken(1UL); + Lifetime.BindEventContext(() => generation, static () => 1UL); + + Movement = new RuntimeLocalPlayerMovementState(); + Identity = new RuntimeLocalPlayerIdentityState(); + Publication = new RuntimeLocalPlayerPhysicsPublicationState( + Lifetime.Entities, Lifetime.Physics, Movement, Identity); + Movement.AttachPhysicsPublication(Publication); + // F2: use the SAME conductor instance RuntimeEntityObjectLifetime + // itself constructs and wires into the residence's multicast + // retirement fan-out and BeginSessionClear — not a separate, + // standalone instance — so these tests exercise the real + // production wiring (automatic convergence on delete/reset/ + // session-clear), not a parallel copy of it. + Conductor = Lifetime.LocalPlayerFirstEntry; + Conductor.BindPublication(Publication); + + Record = Lifetime.RegisterEntityWithInitialResidence( + Spawn(0x70090001u, incarnation: 1, setupTableId: setupTableId), + isLocalPlayer: true).Canonical!; + Identity.ServerGuid = Record.ServerGuid; + Assert.True(Lifetime.TryGetInitialCreateResidence( + Record, out RuntimeInitialCreateResidenceLease lease)); + Lease = lease; + + CollisionSource = new FakeCollisionSource( + setupTableId, + new FlatSetupCollision( + ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeLocalPlayerMovementState Movement { get; } + internal RuntimeLocalPlayerIdentityState Identity { get; } + internal RuntimeLocalPlayerPhysicsPublicationState Publication { get; } + internal RuntimeLocalPlayerFirstEntryState Conductor { get; } + internal RuntimeEntityRecord Record { get; set; } + internal RuntimeInitialCreateResidenceLease Lease { get; set; } + internal FakeCollisionSource CollisionSource { get; } + + internal RuntimeLocalPlayerFirstEntryStatus Advance( + out RuntimeInitialCreateExecutionReceipt receipt) => + Conductor.Advance( + Record, + Lease.Token, + PlayerMovementConstructionOptions.Fallback, + new RuntimeLocalPlayerPhysicsActivationPreparation( + Radius: 0.48f, + Height: 1.835f, + RuntimeLocalPlayerShadowDisposition.ProvenShapeless), + CollisionSource, + gameTime: 10d, + NoContact, + out receipt); + + public void Dispose() + { + // F2: RuntimeEntityObjectLifetime's own Dispose runs + // BeginSessionClear, which now reaches + // LocalPlayerFirstEntry.DiscardAll() -> Publication.Discard for + // any still-tracked entity — Publication must still be alive for + // that. Lifetime must therefore be disposed BEFORE Movement + // (whose Dispose tears down Publication), never after. + Lifetime.Dispose(); + Movement.Dispose(); + Identity.Dispose(); + } + } +}