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();
+ }
+ }
+}