diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
index 6146dc93..a8aef87e 100644
--- a/docs/research/2026-08-01-runtime-local-player-physics-publication.md
+++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
@@ -2,19 +2,20 @@
## Scope
-This is placement Slice 4B2 checkpoints 4-5. It adds the dormant,
+This is placement Slice 4B2 checkpoints 4-6. It adds the dormant,
presentation-independent transaction which prepares and assigns ownership of
-one local-player `PhysicsBody` and `PlayerMovementController`, then retains an
-exact post-ownership activation lease which can evaluate retail SetPosition
-without publishing it. No App or
-Headless production route invokes this transaction, so graphical and no-window
-game behavior is unchanged and AP-1/AD-1 remain open.
+one local-player `PhysicsBody` and `PlayerMovementController`, retains an exact
+post-ownership evaluation lease, and commits the canonical Runtime SetPosition
+activation in retail order. No App or Headless production route invokes this
+transaction yet, so graphical and no-window game behavior is unchanged and
+AP-1/AD-1 remain open until their hosts cut over.
-The checkpoints deliberately stop before canonical SetPosition activation.
-They do not consume the prepared placement operation, enter the body into the
-physics engine, publish FullCell/world/host/shadow/workset state, or project a
-presentation entity. Those effects belong to the next transaction and must all
-use the same Runtime-owned dormant body.
+Checkpoint 6 consumes the prepared placement operation only after the exact
+body/controller/identity/collision envelope is current. It publishes FullCell,
+world residence, host, shadow, workset, object-clock, and ordered Place state
+from that same Runtime-owned dormant body. There is no second body, mirrored
+gameplay owner, rollback mutation, or presentation callback inside the
+canonical tail.
## Ownership contract
@@ -46,8 +47,9 @@ the same server GUID as the exact entity incarnation.
Unpublished candidates and ownership-committed dormant controllers reject live
movement operations: update, public SetPosition, blip, outbound-position
capture, movement/position send tracking, and shared-engine position commit.
-Only the subsequent activation transaction may promote `RuntimeOwnedDormant`
-to `RuntimePublished`; this checkpoint never invokes that transition. Once a
+Only the checkpoint-6 activation transaction may promote `RuntimeOwnedDormant`
+to `RuntimePublished`; preparation and evaluation never invoke that transition.
+Once a
Runtime-owned dormant or published controller is replaced, reset, or disposed,
its terminal retirement state rejects the same operations plus
body/configuration mutation and manager acquisition. Publicly constructed
@@ -106,8 +108,9 @@ transaction is pure: it returns committed, deferred-cell, or rejected
placement data without writing the canonical body, FullCell, clock, spatial
worksets, shadows, collision-report owners, host, operation stage, or Place
projection. A missing cell therefore leaves the exact body dormant and the
-authored operation retryable. A valid result likewise remains only an
-immutable evaluation receipt; this checkpoint has no activation/commit API.
+authored operation retryable. During evaluation, a valid result likewise
+remains only an immutable receipt; checkpoint 6's separate commit API consumes
+that receipt only after revalidating the complete activation envelope.
Each evaluation carries an append-only, stable-order union of every cell read
by the complete Core transaction: the AdjustPosition seed and adjusted cell,
@@ -154,6 +157,71 @@ discard is required before a new candidate can be prepared. Reset and disposal
retire the lease, body, and dormant controller and include the pending
activation in the ownership convergence ledger.
+## Canonical activation and retail ordering
+
+The implementation follows the named-retail chain rather than treating
+SetPosition as a single opaque callback:
+
+- `CPhysicsObj::SetPosition` at `0x005160C0` owns the outer placement call.
+- The internal wrapper at `0x00515BD0` evaluates residence and collision.
+- `CPhysicsObj::SetPositionInternal(CTransition*)` at `0x00515330` commits the
+ accepted frame/contact prefix and later shadow/cell state.
+- `CPhysicsObj::enter_world` at `0x00516170` is the final live edge.
+- `CPhysicsObj::leave_world` at `0x005155A0` is the canonical retirement edge.
+
+Runtime splits that chain into a prepared, callback-free transaction and an
+ordered notification suffix:
+
+1. Install the accepted frame and contact prefix on the still-dormant body and
+ perform the first acceleration calculation.
+2. Open one narrow dormant ground phase and invoke `HitGround` or
+ `LeaveGround`. Movement reapplication may call retail `set_velocity`, but
+ the phase closes with `Active=false`; the body is still out of world, has no
+ host/spatial membership, and its object clock is inactive.
+3. Synchronize accepted State and Vector authorities, run the post-ground
+ acceleration/sliding phase, and dispatch the already-installed collision
+ batch.
+4. Revalidate the complete ownership/collision envelope. Accepted State and
+ Vector updates are synchronized; Position, ObjDesc, Create, Setup,
+ incarnation, identity, collision-generation, body, controller, host, or
+ session displacement aborts the old transaction.
+5. Apply velocity-current physical response and stationary bits, prepare the
+ final shadow mutation and Place receipt, then perform the callback-free
+ FullCell/body/host/controller/spatial/object-clock tail.
+6. Dispatch exact shadow notifications and the ordered Place projection only
+ after the complete live graph is visible.
+
+Collision and shadow mutations use explicit prepare/apply/dispatch receipts.
+Receipt dispatch is exact-once and owner-local, so reverse-order receipts for
+different owners remain valid while a superseding mutation of the same owner
+stops the stale suffix. Collision owner states carry the exact SetPosition
+batch ID. Reentrant Position or newer-batch replacement suppresses remaining
+reciprocal/environment callbacks, and abort cleanup force-ends/removes only the
+still-exact old batch, including reverse rows and the environment latch. The
+combined Runtime physics ownership ledger includes pending collision and
+shadow SetPosition receipts; teardown cannot report convergence while either
+receipt remains.
+
+Candidate construction applies the accepted `PhysicsDesc` values in retail
+`CPhysicsObj::set_description` order before sealing ownership: final state,
+friction, clamped elasticity, `set_velocity` (including the 50-unit clamp),
+and angular velocity. Network acceleration remains parse-only because retail
+recalculates it from the final physics state. This initial vector bootstrap is
+required even when the SetPosition receipt's source Vector authority is still
+current; the later refresh intentionally skips in that case. Collision
+callbacks may advance State/Vector authority without invalidating the
+immutable geometry/identity envelope, and a changed Vector authority refreshes
+the dormant body through the same `set_velocity` path before physical response.
+
+A deferred-cell commit atomically suspends an authored shadow registration and
+consumes its notification receipt. Explicit publication discard cancels the
+exact SetPosition lease and body/controller ownership, while the suspended
+registration remains owned by the live entity/shadow registry and is reusable
+by a later activation. A deterministic discard -> generation-ready -> new
+activation gate proves the same registration restores without stale rows or a
+pending receipt. Entity/lifetime teardown remains the terminal owner of that
+suspended registration.
+
## Gates
- Candidate privacy and live-operation rejection.
@@ -161,15 +229,22 @@ activation in the ownership convergence ledger.
acquisition/binding, and remote-placement ownership.
- Exact local-player identity, identity-switch, and disposed-identity rejection.
- Exact same-body ownership in entity record and dormant movement controller.
-- Dormant rejection after ownership commit plus the isolated controller-level
- `dormant -> activated -> live` lifecycle contract for the next checkpoint.
+- Initial PhysicsDesc velocity, angular velocity, friction, and elasticity
+ bootstrap, including activation with the retail 50-unit velocity clamp.
+- Dormant rejection after ownership commit plus the controller-level
+ `dormant -> activated -> live` lifecycle contract exercised by checkpoint 6.
- No mutation of SetPosition, FullCell, spatial roots, host projections,
- shadows, worksets, world residence, or presentation during this checkpoint.
+ shadows, worksets, world residence, or presentation during preparation or
+ evaluation; the separately gated activation commit owns those mutations.
- Replacement by position, vector, final physics state, object description,
CreateObject, remote/projectile/body/clock/controller ownership, and explicit
placement cancellation.
- Delete plus same-GUID reincarnation.
- Candidate replacement, reset, disposal, and ownership convergence.
+- Publication/activation sequence exhaustion is preflighted before candidate
+ allocation or replacement, leaving no private or canonical owner behind.
+- Shadow-registry reset invalidates even a prepared, unapplied shapeless
+ transaction which owns no logical rows or pending dispatch receipt.
- Pure committed/deferred/rejected SetPosition evaluation with bit-exact
body-state snapshots and no canonical, collision-report, projection,
clock, FullCell, host, shadow, workset, or operation-stage mutation.
@@ -193,30 +268,20 @@ activation in the ownership convergence ledger.
- Terminal stale-controller rejection after replacement, reset, and disposal.
- Body/controller epochs advance only on actual ownership changes.
-The checkpoint-5 focused publication suite passes 70/70 and the focused Core
-SetPosition suite passes 59/59. The complete Runtime project passes 666/666;
-the complete Core project passes 4,230 tests / 1 skip; and the App Runtime
-physics/movement ownership guard passes 6/6. The complete Release solution
-builds with zero errors (three pre-existing App-test nullability warnings are
-outside this checkpoint), and its invariant-culture test gate passes 10,419
-tests / 4 intentional skips. The installed prepared-package gate
-uses the exact local `acdream.pak`. Under the machine's Swedish current culture,
-the same three previously known formatting assertions remain unrelated (`0,5`
-versus `0.5` and localized sky text), so the canonical gate runs under
-invariant culture.
+The checkpoint-6 focused publication/collision suite passes 129/129, the
+focused Core shadow transaction suite passes 16/16, and the complete Runtime
+project passes 695/695 under invariant globalization. The Runtime Release build
+passes with zero warnings and zero errors. Broader Core/App/solution and
+connected gates remain for the parent integration checkpoint. Under the
+machine's Swedish current culture, the three previously known formatting
+assertions remain unrelated (`0,5` versus `0.5` and localized sky text), so the
+canonical Runtime gate runs under invariant globalization.
## Next checkpoint
-Add the canonical Runtime SetPosition activation transaction. Before its
-callback-free tail, it must prepare a presentation-independent Runtime
-`PhysicsHost`/`PositionManager`/`MoveToManager` graph, the existing authoritative
-authored shadow payload (without rebuilding AP-22 shapes), and an exact staged
-collision-report batch. Then it may commit the already-owned dormant body,
-contact/water/walkable/response state, object clock, FullCell, exact shadow,
-spatial/ordinary workset, host, and SetPosition operation versions; seal the
-ordered Place receipt; and invoke the sole `ActivateRuntimePublication`
-transition as the final tickable edge. Collision reports and the Place observer
-publish only afterward and must tolerate delete/reset re-entry without replaying
-physics. Every pre-commit failure leaves the dormant lease and authored
-operation retryable or rejects them; no rollback mutation and no second body or
-controller are allowed.
+Cut the graphical and no-window local-player hosts over to this Runtime-owned
+activation transaction, then delete their duplicate SetPosition
+activation/publication paths. The cutover must preserve the same exact body,
+controller, shadow payload, deferred-cell lease, collision receipt ordering,
+and graceful teardown proven here; no host may reconstruct or replay the
+canonical transaction.
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index c55d72ce..37cd20f7 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -199,6 +199,20 @@ public sealed class PhysicsBody
/// point unchanged for an in-range local.
///
public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal)
+ {
+ StageDormantCellFrame(cellId, worldPos, cellLocal);
+ InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
+ }
+
+ ///
+ /// Installs the exact SetPosition cell/frame while the Runtime owner is
+ /// still dormant. This is the frame half of retail SetPositionInternal;
+ /// enter_world remains a later explicit publication suffix.
+ ///
+ public void StageDormantCellFrame(
+ uint cellId,
+ Vector3 worldPos,
+ Vector3 cellLocal)
{
_position = worldPos;
uint cell = cellId;
@@ -206,7 +220,6 @@ public sealed class PhysicsBody
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
LandDefs.AdjustToOutside(ref cell, ref local);
CellPosition = new Position(cell, new CellFrame(local, Orientation));
- InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
///
diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
index afd699ab..73578ef0 100644
--- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
+++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
@@ -128,27 +128,11 @@ public static class PhysicsObjUpdate
{
ArgumentNullException.ThrowIfNull(body);
- if (previousOnWalkable)
- body.TransientState |= TransientStateFlags.OnWalkable;
- else
- body.TransientState &= ~TransientStateFlags.OnWalkable;
-
- if (inContact)
- body.TransientState |= TransientStateFlags.Contact;
- else
- body.TransientState &= ~TransientStateFlags.Contact;
- body.calc_acceleration();
-
- bool finalOnWalkable = inContact && onWalkable;
- if (finalOnWalkable)
- body.TransientState |= TransientStateFlags.OnWalkable;
- else
- body.TransientState &= ~TransientStateFlags.OnWalkable;
-
- if (body.ContactPlaneIsWater)
- body.TransientState |= TransientStateFlags.WaterContact;
- else
- body.TransientState &= ~TransientStateFlags.WaterContact;
+ bool finalOnWalkable = CommitSetPositionContactPrefix(
+ body,
+ inContact,
+ onWalkable,
+ previousOnWalkable);
if (!previousOnWalkable && finalOnWalkable)
{
@@ -162,10 +146,44 @@ public static class PhysicsObjUpdate
if (isCurrent?.Invoke() == false)
return false;
}
- body.calc_acceleration();
+ CommitSetPositionPostGround(body);
return isCurrent?.Invoke() ?? true;
}
+ public static bool CommitSetPositionContactPrefix(
+ PhysicsBody body,
+ bool inContact,
+ bool onWalkable,
+ bool previousOnWalkable)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ if (previousOnWalkable)
+ body.TransientState |= TransientStateFlags.OnWalkable;
+ else
+ body.TransientState &= ~TransientStateFlags.OnWalkable;
+ if (inContact)
+ body.TransientState |= TransientStateFlags.Contact;
+ else
+ body.TransientState &= ~TransientStateFlags.Contact;
+ body.calc_acceleration();
+ bool finalOnWalkable = inContact && onWalkable;
+ if (finalOnWalkable)
+ body.TransientState |= TransientStateFlags.OnWalkable;
+ else
+ body.TransientState &= ~TransientStateFlags.OnWalkable;
+ if (body.ContactPlaneIsWater)
+ body.TransientState |= TransientStateFlags.WaterContact;
+ else
+ body.TransientState &= ~TransientStateFlags.WaterContact;
+ return finalOnWalkable;
+ }
+
+ public static void CommitSetPositionPostGround(PhysicsBody body)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ body.calc_acceleration();
+ }
+
///
/// retail handle_all_collisions (0x00514780). Reflects or zeros the body's
/// (retail m_velocityVector) based on
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index c16d100a..083011ed 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -79,6 +79,10 @@ public sealed class ShadowObjectRegistry
private readonly HashSet _prefixScratch = new();
private readonly List _removedPrefixScratch = new();
private ulong _mutationRevision;
+ private ulong _nextPreparedSetPositionCommitId;
+ private ulong _lastAppliedSetPositionCommitId;
+ private readonly HashSet _pendingSetPositionDispatches = [];
+ private long _setPositionDispatchFailureCount;
internal event Action? OwnerMutated;
internal event Action? OwnerPrefixMembershipChanged;
@@ -783,6 +787,553 @@ public sealed class ShadowObjectRegistry
}
}
+ ///
+ /// Immutable owner-local shadow transaction prepared before Runtime's
+ /// SetPosition publication tail. The prepared rows are built against an
+ /// isolated registry; applying them never re-runs the flood oracle.
+ ///
+ internal sealed record PreparedSetPositionShadowCommit(
+ ulong CommitId,
+ uint EntityId,
+ ulong ExpectedMutationRevision,
+ ulong ExpectedOwnerVersion,
+ ulong FinalMutationRevision,
+ ulong FinalOwnerVersion,
+ bool ProvenShapeless,
+ PreparedShadowOwnerState? OwnerState,
+ PreparedShadowCellReplacement[] CellReplacements,
+ PreparedShadowPrefixReplacement[] PrefixReplacements,
+ HashSet? OwnerPrefixes,
+ uint[] ChangedPrefixes);
+
+ internal sealed record PreparedShadowCellReplacement(
+ uint CellId,
+ List Entries);
+
+ internal sealed record PreparedShadowPrefixReplacement(
+ uint Prefix,
+ bool Remove,
+ List? Slots,
+ Dictionary? Indices,
+ Stack? FreeSlots);
+
+ internal readonly record struct SetPositionShadowCommitReceipt(
+ ulong CommitId,
+ uint EntityId,
+ ulong OwnerVersion,
+ uint[] ChangedPrefixes,
+ bool Mutated)
+ {
+ internal bool IsValid => CommitId != 0UL && EntityId != 0u;
+ }
+
+ ///
+ /// Prepares the complete owner-row replacement without touching the active
+ /// collision world. A missing registration is accepted only when the
+ /// caller carries an explicit proven-shapeless disposition; absence alone
+ /// is not evidence because an authored BSP payload may still be pending.
+ ///
+ internal bool TryPrepareSetPosition(
+ uint entityId,
+ Vector3 worldPosition,
+ Quaternion worldRotation,
+ uint seedCellId,
+ float worldOffsetX,
+ float worldOffsetY,
+ PhysicsShadowCommitAction action,
+ System.Collections.Immutable.ImmutableArray crossCellIds,
+ bool provenShapeless,
+ bool suspendOwner,
+ out PreparedSetPositionShadowCommit? prepared)
+ {
+ prepared = null;
+ ulong expectedMutation = _mutationRevision;
+ ulong expectedOwner = GetOwnerVersion(entityId);
+ bool hasOwner = TryCaptureOwnerState(
+ entityId,
+ out PreparedShadowOwnerState? source);
+ if (!hasOwner)
+ {
+ if (!provenShapeless)
+ return false;
+ _pendingSetPositionDispatches.EnsureCapacity(
+ _pendingSetPositionDispatches.Count + 1);
+ prepared = new PreparedSetPositionShadowCommit(
+ checked(++_nextPreparedSetPositionCommitId),
+ entityId,
+ expectedMutation,
+ expectedOwner,
+ expectedMutation,
+ expectedOwner,
+ ProvenShapeless: true,
+ OwnerState: null,
+ CellReplacements: [],
+ PrefixReplacements: [],
+ OwnerPrefixes: null,
+ ChangedPrefixes: Array.Empty());
+ return _mutationRevision == expectedMutation
+ && GetOwnerVersion(entityId) == expectedOwner
+ && !HasLogicalOwner(entityId);
+ }
+ if (provenShapeless || source is null)
+ return false;
+
+ var staging = new ShadowObjectRegistry
+ {
+ DataCache = DataCache,
+ };
+ staging.InstallOwnerState(source);
+ if (suspendOwner)
+ {
+ if (!staging.Suspend(entityId))
+ return false;
+ }
+ else
+ {
+ staging.CommitSetPosition(
+ entityId,
+ worldPosition,
+ worldRotation,
+ seedCellId,
+ worldOffsetX,
+ worldOffsetY,
+ action,
+ crossCellIds);
+ }
+ if (!staging.TryCaptureOwnerState(
+ entityId,
+ out PreparedShadowOwnerState? replacement)
+ || replacement is null)
+ {
+ return false;
+ }
+
+ uint[] changedPrefixes = CaptureChangedPrefixes(source, replacement);
+ PreparedShadowCellReplacement[] cellReplacements =
+ PrepareCellReplacements(entityId, source, replacement);
+ HashSet replacementPrefixes = CapturePrefixes(replacement);
+ PreparedShadowPrefixReplacement[] prefixReplacements =
+ PreparePrefixReplacements(
+ entityId,
+ CapturePrefixes(source),
+ replacementPrefixes,
+ changedPrefixes);
+ ulong finalMutation = checked(expectedMutation + 1UL);
+ ulong finalOwner = checked(expectedOwner + 1UL);
+ // Reserve dictionary capacity before the non-fallible publication
+ // suffix. Row/list payloads themselves were allocated in staging.
+ _cells.EnsureCapacity(_cells.Count + replacement.Rows.Count);
+ _entityToCells.EnsureCapacity(_entityToCells.Count + 1);
+ _entityReg.EnsureCapacity(_entityReg.Count + 1);
+ _entityShapes.EnsureCapacity(_entityShapes.Count + 1);
+ _suspendedEntityCells.EnsureCapacity(_suspendedEntityCells.Count + 1);
+ _withdrawnPrefixesByOwner.EnsureCapacity(
+ _withdrawnPrefixesByOwner.Count + 1);
+ _ownerVersions.EnsureCapacity(_ownerVersions.Count + 1);
+ _ownerPrefixes.EnsureCapacity(_ownerPrefixes.Count + 1);
+ _prefixOwnerSlots.EnsureCapacity(
+ _prefixOwnerSlots.Count + changedPrefixes.Length);
+ _prefixOwnerIndices.EnsureCapacity(
+ _prefixOwnerIndices.Count + changedPrefixes.Length);
+ _prefixFreeSlots.EnsureCapacity(
+ _prefixFreeSlots.Count + changedPrefixes.Length);
+ _suspendedEntities.EnsureCapacity(_suspendedEntities.Count + 1);
+ _pendingSetPositionDispatches.EnsureCapacity(
+ _pendingSetPositionDispatches.Count + 1);
+
+ prepared = new PreparedSetPositionShadowCommit(
+ checked(++_nextPreparedSetPositionCommitId),
+ entityId,
+ expectedMutation,
+ expectedOwner,
+ finalMutation,
+ finalOwner,
+ ProvenShapeless: false,
+ replacement,
+ cellReplacements,
+ prefixReplacements,
+ replacementPrefixes,
+ changedPrefixes);
+ return _mutationRevision == expectedMutation
+ && GetOwnerVersion(entityId) == expectedOwner
+ && HasLogicalOwner(entityId);
+ }
+
+ ///
+ /// Applies a previously prepared owner-local row swap with callbacks
+ /// suppressed. Runtime dispatches the returned exact notification only
+ /// after the complete SetPosition state suffix is visible.
+ ///
+ internal bool TryApplySetPosition(
+ PreparedSetPositionShadowCommit prepared,
+ out SetPositionShadowCommitReceipt receipt)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+ receipt = default;
+ if (prepared.CommitId <= _lastAppliedSetPositionCommitId
+ || _mutationRevision != prepared.ExpectedMutationRevision
+ || GetOwnerVersion(prepared.EntityId)
+ != prepared.ExpectedOwnerVersion
+ || HasLogicalOwner(prepared.EntityId)
+ == prepared.ProvenShapeless)
+ {
+ return false;
+ }
+
+ if (prepared.ProvenShapeless)
+ {
+ receipt = new SetPositionShadowCommitReceipt(
+ prepared.CommitId,
+ prepared.EntityId,
+ prepared.ExpectedOwnerVersion,
+ Array.Empty(),
+ Mutated: false);
+ _lastAppliedSetPositionCommitId = prepared.CommitId;
+ _pendingSetPositionDispatches.Add(prepared.CommitId);
+ return true;
+ }
+ if (prepared.OwnerState is null)
+ return false;
+
+ for (int index = 0; index < prepared.CellReplacements.Length; index++)
+ {
+ PreparedShadowCellReplacement replacement =
+ prepared.CellReplacements[index];
+ _cells[replacement.CellId] = replacement.Entries;
+ }
+ PreparedShadowOwnerState state = prepared.OwnerState;
+ _entityReg[prepared.EntityId] = state.Registration;
+ ReplaceOwnerValue(_entityShapes, prepared.EntityId, state.Shapes);
+ if (state.Suspended)
+ _suspendedEntities.Add(prepared.EntityId);
+ else
+ _suspendedEntities.Remove(prepared.EntityId);
+ ReplaceOwnerValue(
+ _suspendedEntityCells,
+ prepared.EntityId,
+ state.SuspendedCellIds);
+ ReplaceOwnerValue(
+ _withdrawnPrefixesByOwner,
+ prepared.EntityId,
+ state.WithdrawnPrefixes);
+ ReplaceOwnerValue(
+ _entityToCells,
+ prepared.EntityId,
+ state.CellIds);
+ if (prepared.OwnerPrefixes is not null)
+ _ownerPrefixes[prepared.EntityId] = prepared.OwnerPrefixes;
+ for (int index = 0; index < prepared.PrefixReplacements.Length; index++)
+ {
+ PreparedShadowPrefixReplacement replacement =
+ prepared.PrefixReplacements[index];
+ if (replacement.Remove)
+ {
+ _prefixOwnerSlots.Remove(replacement.Prefix);
+ _prefixOwnerIndices.Remove(replacement.Prefix);
+ _prefixFreeSlots.Remove(replacement.Prefix);
+ continue;
+ }
+ _prefixOwnerSlots[replacement.Prefix] = replacement.Slots!;
+ _prefixOwnerIndices[replacement.Prefix] = replacement.Indices!;
+ _prefixFreeSlots[replacement.Prefix] = replacement.FreeSlots!;
+ }
+ _mutationRevision = prepared.FinalMutationRevision;
+ _ownerVersions[prepared.EntityId] = prepared.FinalOwnerVersion;
+ _lastAppliedSetPositionCommitId = prepared.CommitId;
+ _pendingSetPositionDispatches.Add(prepared.CommitId);
+ receipt = new SetPositionShadowCommitReceipt(
+ prepared.CommitId,
+ prepared.EntityId,
+ prepared.FinalOwnerVersion,
+ prepared.ChangedPrefixes,
+ Mutated: true);
+ return true;
+ }
+
+ internal bool IsPreparedSetPositionCurrent(
+ PreparedSetPositionShadowCommit prepared)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+ return prepared.CommitId > _lastAppliedSetPositionCommitId
+ && _mutationRevision == prepared.ExpectedMutationRevision
+ && GetOwnerVersion(prepared.EntityId)
+ == prepared.ExpectedOwnerVersion
+ && HasLogicalOwner(prepared.EntityId)
+ != prepared.ProvenShapeless;
+ }
+
+ internal void DispatchSetPositionCommit(
+ in SetPositionShadowCommitReceipt receipt)
+ {
+ if (!receipt.IsValid
+ || receipt.CommitId > _lastAppliedSetPositionCommitId
+ || !_pendingSetPositionDispatches.Remove(receipt.CommitId))
+ return;
+ if (!receipt.Mutated)
+ return;
+ ulong currentOwnerVersion = GetOwnerVersion(receipt.EntityId);
+ if (!HasLogicalOwner(receipt.EntityId)
+ || currentOwnerVersion != receipt.OwnerVersion)
+ {
+ return;
+ }
+ for (int index = 0; index < receipt.ChangedPrefixes.Length; index++)
+ {
+ if (!HasLogicalOwner(receipt.EntityId)
+ || GetOwnerVersion(receipt.EntityId) != receipt.OwnerVersion)
+ {
+ return;
+ }
+ DispatchSetPositionPrefixObservers(
+ receipt.EntityId,
+ receipt.ChangedPrefixes[index]);
+ }
+ if (!HasLogicalOwner(receipt.EntityId))
+ return;
+ currentOwnerVersion = GetOwnerVersion(receipt.EntityId);
+ if (currentOwnerVersion != receipt.OwnerVersion)
+ return;
+ DispatchSetPositionOwnerObservers(
+ receipt.EntityId,
+ currentOwnerVersion);
+ }
+
+ internal bool DiscardSetPositionCommit(
+ in SetPositionShadowCommitReceipt receipt) =>
+ receipt.IsValid
+ && _pendingSetPositionDispatches.Remove(receipt.CommitId);
+
+ internal int PendingSetPositionDispatchCount =>
+ _pendingSetPositionDispatches.Count;
+
+ internal long SetPositionDispatchFailureCount =>
+ _setPositionDispatchFailureCount;
+
+ private void DispatchSetPositionPrefixObservers(uint owner, uint prefix)
+ {
+ Action? observers = OwnerPrefixMembershipChanged;
+ if (observers is null)
+ return;
+ foreach (Action observer in observers.GetInvocationList())
+ {
+ try
+ {
+ observer(owner, prefix);
+ }
+ catch
+ {
+ _setPositionDispatchFailureCount++;
+ }
+ }
+ }
+
+ private void DispatchSetPositionOwnerObservers(uint owner, ulong version)
+ {
+ Action? observers = OwnerMutated;
+ if (observers is null)
+ return;
+ foreach (Action observer in observers.GetInvocationList())
+ {
+ try
+ {
+ observer(owner, version);
+ }
+ catch
+ {
+ _setPositionDispatchFailureCount++;
+ }
+ }
+ }
+
+ private static uint[] CaptureChangedPrefixes(
+ PreparedShadowOwnerState before,
+ PreparedShadowOwnerState after)
+ {
+ HashSet oldPrefixes = CapturePrefixes(before);
+ HashSet newPrefixes = CapturePrefixes(after);
+ var changed = new List();
+ foreach (uint prefix in oldPrefixes)
+ {
+ if (!newPrefixes.Contains(prefix))
+ changed.Add(prefix);
+ }
+ foreach (uint prefix in newPrefixes)
+ {
+ if (!oldPrefixes.Contains(prefix))
+ changed.Add(prefix);
+ }
+ changed.Sort();
+ return changed.ToArray();
+ }
+
+ private static HashSet CapturePrefixes(
+ PreparedShadowOwnerState state)
+ {
+ var prefixes = new HashSet
+ {
+ state.Registration.SeedCellId & 0xFFFF0000u,
+ };
+ if (state.CellIds is not null)
+ {
+ for (int index = 0; index < state.CellIds.Count; index++)
+ prefixes.Add(state.CellIds[index] & 0xFFFF0000u);
+ }
+ if (state.WithdrawnPrefixes is not null)
+ {
+ foreach (uint prefix in state.WithdrawnPrefixes)
+ prefixes.Add(prefix & 0xFFFF0000u);
+ }
+ return prefixes;
+ }
+
+ private PreparedShadowCellReplacement[] PrepareCellReplacements(
+ uint entityId,
+ PreparedShadowOwnerState before,
+ PreparedShadowOwnerState after)
+ {
+ var touched = new HashSet();
+ AddCells(touched, before.CellIds);
+ AddCells(touched, after.CellIds);
+ var afterRows = new Dictionary();
+ for (int index = 0; index < after.Rows.Count; index++)
+ {
+ PreparedShadowCellRows row = after.Rows[index];
+ touched.Add(row.CellId);
+ afterRows[row.CellId] = row.Entries;
+ }
+ for (int index = 0; index < before.Rows.Count; index++)
+ touched.Add(before.Rows[index].CellId);
+
+ uint[] ordered = touched.ToArray();
+ Array.Sort(ordered);
+ var result = new PreparedShadowCellReplacement[ordered.Length];
+ for (int index = 0; index < ordered.Length; index++)
+ {
+ uint cellId = ordered[index];
+ _cells.TryGetValue(cellId, out List? active);
+ afterRows.TryGetValue(cellId, out ShadowEntry[]? ownerRows);
+ int retainedCount = 0;
+ if (active is not null)
+ {
+ for (int row = 0; row < active.Count; row++)
+ {
+ if (active[row].EntityId != entityId)
+ retainedCount++;
+ }
+ }
+ var replacement = new List(
+ retainedCount + (ownerRows?.Length ?? 0));
+ if (active is not null)
+ {
+ for (int row = 0; row < active.Count; row++)
+ {
+ if (active[row].EntityId != entityId)
+ replacement.Add(active[row]);
+ }
+ }
+ if (ownerRows is not null)
+ replacement.AddRange(ownerRows);
+ result[index] = new PreparedShadowCellReplacement(
+ cellId,
+ replacement);
+ }
+ return result;
+ }
+
+ private PreparedShadowPrefixReplacement[] PreparePrefixReplacements(
+ uint entityId,
+ HashSet before,
+ HashSet after,
+ uint[] changedPrefixes)
+ {
+ var result = new PreparedShadowPrefixReplacement[
+ changedPrefixes.Length];
+ for (int index = 0; index < changedPrefixes.Length; index++)
+ {
+ uint prefix = changedPrefixes[index];
+ bool removeOwner = before.Contains(prefix)
+ && !after.Contains(prefix);
+ _prefixOwnerSlots.TryGetValue(prefix, out List? oldSlots);
+ _prefixOwnerIndices.TryGetValue(
+ prefix,
+ out Dictionary? oldIndices);
+ _prefixFreeSlots.TryGetValue(prefix, out Stack? oldFree);
+ var slots = oldSlots is null ? [] : new List(oldSlots);
+ var indices = oldIndices is null
+ ? new Dictionary()
+ : new Dictionary(oldIndices);
+ Stack free = CloneStack(oldFree);
+ if (removeOwner)
+ {
+ if (indices.Remove(entityId, out int ownerSlot))
+ {
+ slots[ownerSlot] = 0u;
+ free.Push(ownerSlot);
+ }
+ result[index] = indices.Count == 0
+ ? new PreparedShadowPrefixReplacement(
+ prefix,
+ Remove: true,
+ Slots: null,
+ Indices: null,
+ FreeSlots: null)
+ : new PreparedShadowPrefixReplacement(
+ prefix,
+ Remove: false,
+ slots,
+ indices,
+ free);
+ continue;
+ }
+
+ if (!indices.ContainsKey(entityId))
+ {
+ if (free.TryPop(out int freeIndex))
+ {
+ slots[freeIndex] = entityId;
+ indices[entityId] = freeIndex;
+ }
+ else
+ {
+ indices[entityId] = slots.Count;
+ slots.Add(entityId);
+ }
+ }
+ result[index] = new PreparedShadowPrefixReplacement(
+ prefix,
+ Remove: false,
+ slots,
+ indices,
+ free);
+ }
+ return result;
+ }
+
+ private static Stack CloneStack(Stack? source) =>
+ source is null
+ ? new Stack()
+ : new Stack(source.Reverse());
+
+ private static void AddCells(HashSet destination, List? cells)
+ {
+ if (cells is null)
+ return;
+ for (int index = 0; index < cells.Count; index++)
+ destination.Add(cells[index]);
+ }
+
+ private static void ReplaceOwnerValue(
+ Dictionary destination,
+ uint entityId,
+ T? value)
+ where T : class
+ {
+ if (value is null)
+ destination.Remove(entityId);
+ else
+ destination[entityId] = value;
+ }
+
private void RefreshPositionRows(
uint entityId,
RegistrationRecord registration,
@@ -1896,7 +2447,9 @@ public sealed class ShadowObjectRegistry
|| _entityToCells.Count != 0
|| _entityReg.Count != 0
|| _suspendedEntities.Count != 0
- || _suspendedEntityCells.Count != 0;
+ || _suspendedEntityCells.Count != 0
+ || _nextPreparedSetPositionCommitId
+ != _lastAppliedSetPositionCommitId;
if (mutated)
AdvanceMutationRevision();
_cells.Clear();
@@ -1916,6 +2469,7 @@ public sealed class ShadowObjectRegistry
_ownerFreeSlots.Clear();
_prefixScratch.Clear();
_removedPrefixScratch.Clear();
+ _pendingSetPositionDispatches.Clear();
_fallback = null;
}
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 6c6c2f24..cc2e8fd5 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -707,6 +707,57 @@ public sealed class PlayerMovementController
internal bool IsRuntimeOwnedDormant => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant;
+ internal bool IsRuntimePublished => _publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.RuntimePublished;
+
+ private bool _dormantSetPositionGroundPhase;
+
+ internal void BeginDormantSetPositionGroundPhase()
+ {
+ if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
+ throw new InvalidOperationException(
+ "Dormant SetPosition ground phase requires one dormant Runtime owner.");
+ _dormantSetPositionGroundPhase = true;
+ }
+
+ internal void EndDormantSetPositionGroundPhase()
+ {
+ if (!_dormantSetPositionGroundPhase)
+ throw new InvalidOperationException(
+ "Dormant SetPosition ground phase is not active.");
+ _body.TransientState &= ~TransientStateFlags.Active;
+ _dormantSetPositionGroundPhase = false;
+ }
+
+ internal bool IsDormantSetPositionGroundPhaseActive =>
+ _dormantSetPositionGroundPhase;
+
+ internal void RefreshDormantRuntimePhysicsState(
+ PhysicsStateFlags state,
+ bool recalculateAcceleration)
+ {
+ if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
+ throw new InvalidOperationException(
+ "Only an idle dormant Runtime owner can refresh physics state.");
+ _body.State = state;
+ if (recalculateAcceleration)
+ _body.calc_acceleration();
+ }
+
+ internal void RefreshDormantRuntimeVector(
+ Vector3? velocity,
+ Vector3? omega)
+ {
+ if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase)
+ throw new InvalidOperationException(
+ "Only an idle dormant Runtime owner can refresh vector state.");
+ if (velocity is { } liveVelocity)
+ _body.set_velocity(liveVelocity);
+ if (omega is { } liveOmega)
+ _body.Omega = liveOmega;
+ _body.TransientState &= ~TransientStateFlags.Active;
+ }
+
internal void SealPublicationCandidate()
{
if (_publicationLifecycle
@@ -738,7 +789,8 @@ public sealed class PlayerMovementController
{
if (_publicationLifecycle
is not PlayerMovementControllerPublicationLifecycle
- .RuntimeOwnedDormant)
+ .RuntimeOwnedDormant
+ || _dormantSetPositionGroundPhase)
{
throw new InvalidOperationException(
"Only a dormant Runtime-owned movement controller can be activated.");
@@ -747,6 +799,30 @@ public sealed class PlayerMovementController
.RuntimePublished;
}
+ ///
+ /// Installs the already-committed Runtime SetPosition frame into the
+ /// controller's interpolation/cell sidecar without invoking the public
+ /// teleport path. The canonical body is written by Runtime first; this
+ /// method only makes the controller's private frame agree while it is
+ /// still dormant. It deliberately does not touch CellGraph, movement,
+ /// PositionManager, the object clock, or callbacks.
+ ///
+ internal void CommitRuntimeActivationFrame()
+ {
+ if (_publicationLifecycle
+ is not PlayerMovementControllerPublicationLifecycle
+ .RuntimeOwnedDormant
+ || _dormantSetPositionGroundPhase)
+ {
+ throw new InvalidOperationException(
+ "Only a dormant Runtime-owned movement controller can accept its activation frame.");
+ }
+
+ _prevPhysicsPos = _body.Position;
+ _currPhysicsPos = _body.Position;
+ CellId = _body.CellPosition.ObjCellId;
+ }
+
internal void DiscardRuntimeCandidate()
{
if (_publicationLifecycle
@@ -775,7 +851,8 @@ public sealed class PlayerMovementController
is PlayerMovementControllerPublicationLifecycle.StandalonePublished
or PlayerMovementControllerPublicationLifecycle
.CandidatePreparing
- or PlayerMovementControllerPublicationLifecycle.RuntimePublished)
+ or PlayerMovementControllerPublicationLifecycle.RuntimePublished
+ || IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase)
{
return;
}
@@ -807,6 +884,9 @@ public sealed class PlayerMovementController
if ((_body.State & PhysicsStateFlags.Static) != 0)
return;
+ if (IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase)
+ return;
+
_objectClock.Activate();
_body.TransientState |= TransientStateFlags.Active;
}
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
index 17510ac7..b57ee1dd 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Physics;
+using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
@@ -23,6 +24,26 @@ internal enum RuntimeLocalPlayerPhysicsActivationStatus
RejectedToken,
}
+internal enum RuntimeLocalPlayerShadowDisposition : byte
+{
+ RegisteredAuthoredPayload,
+ ProvenShapeless,
+}
+
+internal readonly record struct RuntimeLocalPlayerPhysicsActivationPreparation(
+ float Radius,
+ float Height,
+ RuntimeLocalPlayerShadowDisposition ShadowDisposition)
+{
+ internal bool IsValid => float.IsFinite(Radius)
+ && Radius >= 0f
+ && float.IsFinite(Height)
+ && Height >= 0f
+ && ShadowDisposition is RuntimeLocalPlayerShadowDisposition
+ .RegisteredAuthoredPayload
+ or RuntimeLocalPlayerShadowDisposition.ProvenShapeless;
+}
+
internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
RuntimeEntityKey Entity,
RuntimeEntityPlacementToken Placement,
@@ -111,6 +132,12 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
{ get; init; }
internal required PlayerMovementController Controller { get; init; }
internal required PhysicsBody Body { get; init; }
+ internal required EntityPhysicsHost PhysicsHost { get; init; }
+ internal required MovementManager Movement { get; init; }
+ internal required MotionInterpreter Motion { get; init; }
+ internal required RuntimeLocalPlayerPhysicsActivationPreparation
+ ActivationPreparation { get; init; }
+ internal required Activation PreparedActivation { get; init; }
}
private sealed class Activation
@@ -122,8 +149,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
{ get; init; }
internal required PlayerMovementController Controller { get; init; }
internal required PhysicsBody Body { get; init; }
+ internal required EntityPhysicsHost PhysicsHost { get; init; }
+ internal required MovementManager Movement { get; init; }
+ internal required MotionInterpreter Motion { get; init; }
+ internal required RuntimeLocalPlayerPhysicsActivationPreparation
+ ActivationPreparation { get; init; }
internal RuntimeLocalPlayerPhysicsActivationReceipt Receipt
{ get; set; }
+ internal RuntimeDormantSetPositionCommitReceipt PendingFinalCommit
+ { get; set; }
}
private readonly RuntimeEntityDirectory _entities;
@@ -135,6 +169,7 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
private ulong _nextPublicationId;
private ulong _nextActivationId;
private ulong _nextEvaluationId;
+ private long _activationDispatchFailureCount;
private bool _disposed;
internal RuntimeLocalPlayerPhysicsPublicationState(
@@ -154,14 +189,25 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
in RuntimeEntityPlacementToken placement,
in RuntimeSetPositionCommand command,
PlayerMovementConstructionOptions options,
+ in RuntimeLocalPlayerPhysicsActivationPreparation activationPreparation,
out RuntimeLocalPlayerPhysicsPublicationToken token)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(record);
token = default;
- if (!CanPrepare(record, placement, command))
+ if (!activationPreparation.IsValid
+ || !CanPrepare(record, placement, command))
return RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority;
+ // Exhaustion is checked before allocating or replacing a private
+ // candidate. A checked overflow must not strand an unowned controller
+ // or discard an already-prepared exact receipt.
+ ulong publicationId = checked(_nextPublicationId + 1UL);
+ ulong activationId = checked(_nextActivationId + 1UL);
+
+ RuntimeLocalPlayerPhysicsActivationPreparation preparedActivation =
+ activationPreparation;
+
var controller = PlayerMovementController.CreatePublicationCandidate(
_physics.Engine,
options);
@@ -177,6 +223,69 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
controller.SetBodyOrientation(command.Physics.Orientation);
controller.ApplyPhysicsState(record.FinalPhysicsState);
PhysicsBody body = controller.PhysicsBody;
+ var physics = record.Snapshot.Physics;
+ body.Friction = NormalizeFriction(
+ physics?.Friction ?? record.Snapshot.Friction);
+ body.Elasticity = NormalizeElasticity(
+ physics?.Elasticity
+ ?? record.Snapshot.Elasticity
+ ?? body.Elasticity);
+ if (physics?.Velocity is { } initialVelocity)
+ body.set_velocity(initialVelocity);
+ if (physics?.AngularVelocity is { } initialOmega)
+ body.Omega = initialOmega;
+ MovementManager movement = controller.Movement;
+ MotionInterpreter motion = controller.Motion;
+ EntityPhysicsHost physicsHost = null!;
+ movement.MoveToFactory = () =>
+ {
+ var moveTo = new MoveToManager(
+ motion,
+ stopCompletely: () =>
+ _ = controller.StopCompletelyAtPhysicsObjectBoundary(),
+ getPosition: () => body.CellPosition,
+ getHeading: () => MoveToMath.GetHeading(body.Orientation),
+ setHeading: (heading, _) => body.Orientation =
+ MoveToMath.SetHeading(body.Orientation, heading),
+ getOwnRadius: () => preparedActivation.Radius,
+ getOwnHeight: () => preparedActivation.Height,
+ contact: () => body.InContact,
+ isInterpolating: static () => false,
+ getVelocity: () => body.Velocity,
+ getSelfId: () => record.ServerGuid,
+ setTarget: (context, target, radius, quantum) =>
+ physicsHost.SetTarget(context, target, radius, quantum),
+ clearTarget: () => physicsHost.ClearTarget(),
+ getTargetQuantum: () =>
+ physicsHost.TargetManager.GetTargetQuantum(),
+ setTargetQuantum: quantum =>
+ physicsHost.TargetManager.SetTargetQuantum(quantum),
+ curTime: () => controller.SimTimeSeconds);
+ moveTo.StickTo = (target, radius, height) =>
+ physicsHost.PositionManager.StickTo(target, radius, height);
+ moveTo.Unstick = physicsHost.PositionManager.UnStick;
+ return moveTo;
+ };
+ physicsHost = new EntityPhysicsHost(
+ record.ServerGuid,
+ getPosition: () => body.CellPosition,
+ getVelocity: () => body.Velocity,
+ getRadius: () => preparedActivation.Radius,
+ inContact: () => body.InContact,
+ minterpMaxSpeed: () => motion.GetAdjustedMaxSpeed(),
+ curTime: () => controller.SimTimeSeconds,
+ physicsTimerTime: () => controller.SimTimeSeconds,
+ getObjectA: id => _physics.TryGetPhysicsHost(id, out var host)
+ ? host
+ : null,
+ handleUpdateTarget: movement.HandleUpdateTarget,
+ interruptCurrentMovement: () =>
+ movement.CancelMoveTo(WeenieError.ActionCancelled));
+ movement.MakeMoveToManager();
+ motion.UnstickFromObject = physicsHost.PositionManager.UnStick;
+ motion.InterruptCurrentMovement = () =>
+ movement.CancelMoveTo(WeenieError.ActionCancelled);
+ controller.PositionManager = physicsHost.PositionManager;
// This checkpoint publishes ownership only. The subsequent canonical
// SetPosition transaction is the sole authority which may enter the
// body into world simulation and activate its ordinary workset.
@@ -194,16 +303,41 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
}
DiscardCurrent();
+ _nextPublicationId = publicationId;
+ _nextActivationId = activationId;
token = new RuntimeLocalPlayerPhysicsPublicationToken(
record.Key.Value,
placement,
- checked(++_nextPublicationId),
+ publicationId,
_identity.ServerGuid,
_identity.Revision,
record.PhysicsOwnershipEpoch,
record.ObjectClockEpoch,
_movement.ControllerOwnershipEpoch,
_entities.SessionLifetimeVersion);
+ ulong expectedControllerEpoch = checked(
+ _movement.ControllerOwnershipEpoch + 1UL);
+ var activationEnvelope = new Activation
+ {
+ Token = new RuntimeLocalPlayerPhysicsActivationToken(
+ token.Entity,
+ token.Placement,
+ activationId,
+ token.LocalPlayerServerGuid,
+ token.LocalPlayerIdentityRevision,
+ checked(token.PhysicsOwnershipEpoch + 1UL),
+ token.ObjectClockEpoch,
+ expectedControllerEpoch,
+ token.SessionGenerationAuthority),
+ Record = record,
+ PlacementCommand = command,
+ Controller = controller,
+ Body = body,
+ PhysicsHost = physicsHost,
+ Movement = movement,
+ Motion = motion,
+ ActivationPreparation = preparedActivation,
+ };
_candidate = new Candidate
{
Token = token,
@@ -211,10 +345,27 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
PlacementCommand = command,
Controller = controller,
Body = body,
+ PhysicsHost = physicsHost,
+ Movement = movement,
+ Motion = motion,
+ ActivationPreparation = preparedActivation,
+ PreparedActivation = activationEnvelope,
};
return RuntimeLocalPlayerPhysicsPublicationStatus.Prepared;
}
+ private static float NormalizeFriction(float? value) =>
+ value is >= 0f and <= 1f && float.IsFinite(value.Value)
+ ? value.Value
+ : PhysicsBody.DefaultFriction;
+
+ private static float NormalizeElasticity(float value)
+ {
+ if (float.IsNaN(value) || value <= 0f)
+ return 0f;
+ return MathF.Min(value, 0.1f);
+ }
+
internal RuntimeLocalPlayerPhysicsPublicationStatus Commit(
in RuntimeLocalPlayerPhysicsPublicationToken token) =>
Commit(token, out _);
@@ -245,24 +396,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
candidate.Record.ObjectClock);
candidate.Record.SetPhysicsBody(candidate.Body);
_movement.CommitRuntimeOwnedController(candidate.Controller);
- activationToken = new RuntimeLocalPlayerPhysicsActivationToken(
- candidate.Token.Entity,
- candidate.Token.Placement,
- checked(++_nextActivationId),
- candidate.Token.LocalPlayerServerGuid,
- candidate.Token.LocalPlayerIdentityRevision,
- candidate.Record.PhysicsOwnershipEpoch,
- candidate.Record.ObjectClockEpoch,
- _movement.ControllerOwnershipEpoch,
- _entities.SessionLifetimeVersion);
- _activation = new Activation
- {
- Token = activationToken,
- Record = candidate.Record,
- PlacementCommand = candidate.PlacementCommand,
- Controller = candidate.Controller,
- Body = candidate.Body,
- };
+ activationToken = candidate.PreparedActivation.Token;
+ _activation = candidate.PreparedActivation;
_candidate = null;
return RuntimeLocalPlayerPhysicsPublicationStatus.Committed;
}
@@ -291,6 +426,16 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
activation.PlacementCommand,
out RuntimeDormantSetPositionEvaluation placement))
{
+ if (ReferenceEquals(_activation, activation)
+ && IsActivationCurrent(activation)
+ && _physics.SetPosition.IsDormantLocalActivationAwaitingCell(
+ activation.Record,
+ activation.Body,
+ token.Placement,
+ activation.PlacementCommand))
+ {
+ return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell;
+ }
if (ReferenceEquals(_activation, activation)
&& !IsActivationCurrent(activation))
{
@@ -329,6 +474,304 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
receipt.Placement);
}
+ internal RuntimeDormantSetPositionCommitStatus CommitActivation(
+ in RuntimeLocalPlayerPhysicsActivationReceipt receipt,
+ out RuntimePlacementProjectionToken projection)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ projection = default;
+ if (!receipt.IsValid
+ || _activation is not { } activation
+ || activation.Token != receipt.Token
+ || activation.Receipt != receipt)
+ {
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (activation.PendingFinalCommit.Status
+ is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation)
+ {
+ return FinalizeActivation(
+ activation,
+ activation.PendingFinalCommit,
+ out projection);
+ }
+ if (!IsActivationCurrent(activation)
+ || !_physics.SetPosition.IsDormantLocalEvaluationCurrent(
+ activation.Record,
+ activation.Body,
+ receipt.Placement))
+ {
+ activation.Receipt = default;
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+
+ bool provenShapeless = activation.ActivationPreparation
+ .ShadowDisposition
+ is RuntimeLocalPlayerShadowDisposition.ProvenShapeless;
+ if (!_physics.SetPosition.TryPrepareDormantLocalActivationCommit(
+ activation.Record,
+ activation.Body,
+ receipt.Placement,
+ provenShapeless,
+ out PreparedDormantSetPositionCommit? prepared)
+ || prepared is null
+ || !IsActivationCurrent(activation))
+ {
+ activation.Receipt = default;
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+
+ if (!_physics.SetPosition.TryApplyDormantLocalActivationCommit(
+ activation.Record,
+ activation.Body,
+ activation.Controller,
+ activation.PhysicsHost,
+ prepared,
+ out RuntimeDormantSetPositionCommitReceipt committed))
+ {
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (committed.Status is RuntimeDormantSetPositionCommitStatus.DeferredCell)
+ {
+ activation.Receipt = default;
+ _physics.SetPosition.DispatchDormantLocalActivationShadow(committed);
+ return committed.Status;
+ }
+
+ // Named retail SetPosition: contact prefix, ground edge, second
+ // acceleration/sliding, collision callbacks, physical response, then
+ // shadow/live publication.
+ try
+ {
+ activation.Controller.BeginDormantSetPositionGroundPhase();
+ if (committed.HitGround)
+ activation.Movement.HitGround();
+ else if (committed.LeaveGround)
+ activation.Motion.LeaveGround();
+ }
+ catch
+ {
+ _activationDispatchFailureCount++;
+ }
+ finally
+ {
+ activation.Controller.EndDormantSetPositionGroundPhase();
+ }
+ if (committed.Status is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation
+ && (!IsActivationPrephaseEnvelopeCurrent(activation, committed)
+ || !RefreshDormantVector(activation, committed)
+ || !RefreshDormantState(activation)
+ || !_physics.SetPosition.CommitDormantLocalActivationPostGround(
+ activation.Record,
+ activation.Body,
+ committed)))
+ {
+ AbortActivation(
+ activation, committed, collisionAlreadyDispatched: false);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ try
+ {
+ SetPositionCollisionBatchDispatchResult collisionDispatch =
+ _physics.SetPosition.DispatchDormantLocalActivationCollision(
+ committed);
+ if (collisionDispatch.Status
+ is not SetPositionCollisionBatchDispatchStatus.Completed)
+ {
+ AbortActivation(
+ activation, committed, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ }
+ catch
+ {
+ _activationDispatchFailureCount++;
+ AbortActivation(
+ activation, committed, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (!IsActivationResponseEnvelopeCurrent(activation, committed))
+ {
+ AbortActivation(
+ activation, committed, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ activation.Controller.RefreshDormantRuntimePhysicsState(
+ activation.Record.FinalPhysicsState,
+ recalculateAcceleration: false);
+ _ = RefreshDormantVector(activation, committed);
+ if (committed.Status is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation
+ && !IsActivationPrephaseEnvelopeCurrent(activation, committed)
+ || !_physics.SetPosition.CommitDormantLocalActivationPostCollision(
+ activation.Record,
+ activation.Body,
+ committed))
+ {
+ AbortActivation(
+ activation, committed, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (committed.Status is RuntimeDormantSetPositionCommitStatus
+ .RejectedPlacement)
+ {
+ activation.Receipt = default;
+ activation.PendingFinalCommit = committed;
+ return committed.Status;
+ }
+ activation.PendingFinalCommit = committed;
+ return FinalizeActivation(activation, committed, out projection);
+ }
+
+ private RuntimeDormantSetPositionCommitStatus FinalizeActivation(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt prephase,
+ out RuntimePlacementProjectionToken projection)
+ {
+ projection = default;
+ if (!IsActivationPrephaseEnvelopeCurrent(activation, prephase))
+ {
+ AbortActivation(
+ activation, prephase, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ activation.Controller.RefreshDormantRuntimePhysicsState(
+ activation.Record.FinalPhysicsState,
+ recalculateAcceleration: false);
+ bool provenShapeless = activation.ActivationPreparation
+ .ShadowDisposition is RuntimeLocalPlayerShadowDisposition.ProvenShapeless;
+ if (!_physics.SetPosition.TryPrepareDormantLocalActivationFinalCommit(
+ activation.Record,
+ activation.Body,
+ prephase,
+ provenShapeless,
+ out PreparedDormantActivationFinalCommit? prepared)
+ || prepared is null)
+ {
+ if (IsActivationPrephaseEnvelopeCurrent(activation, prephase))
+ return prephase.Status;
+ AbortActivation(
+ activation, prephase, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (!IsActivationPrephaseEnvelopeCurrent(activation, prephase))
+ {
+ AbortActivation(
+ activation, prephase, collisionAlreadyDispatched: true);
+ return RuntimeDormantSetPositionCommitStatus.RejectedAuthority;
+ }
+ if (!_physics.SetPosition.TryApplyDormantLocalActivationFinalCommit(
+ activation.Record,
+ activation.Body,
+ activation.Controller,
+ activation.PhysicsHost,
+ prephase,
+ prepared,
+ out RuntimeDormantSetPositionCommitReceipt committed))
+ {
+ return prephase.Status;
+ }
+ activation.Receipt = default;
+ activation.PendingFinalCommit = default;
+ _activation = null;
+ _physics.SetPosition.DispatchDormantLocalActivationShadow(committed);
+ if (!IsCommittedActivationSuffixCurrent(activation, committed))
+ return committed.Status;
+ _physics.SetPosition.DispatchDormantLocalActivationPlacement(committed);
+ projection = committed.Projection.Token;
+ return committed.Status;
+ }
+
+ private bool IsActivationPrephaseEnvelopeCurrent(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt receipt) =>
+ IsActivationOwnershipEnvelopeCurrent(activation)
+ && _physics.IsCollisionEvaluationFatalAuthorityCurrent(
+ receipt.CollisionAuthority)
+ && _physics.SetPosition.IsDormantLocalActivationPrephaseCurrent(
+ activation.Record,
+ activation.Body,
+ receipt);
+
+ private bool IsActivationResponseEnvelopeCurrent(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt receipt) =>
+ IsActivationOwnershipEnvelopeCurrent(activation)
+ && _physics.IsCollisionEvaluationFatalAuthorityCurrent(
+ receipt.CollisionAuthority)
+ && _physics.SetPosition.IsDormantLocalActivationResponseCurrent(
+ activation.Record,
+ activation.Body,
+ receipt);
+
+ private bool IsActivationOwnershipEnvelopeCurrent(
+ Activation activation) =>
+ activation.Controller.IsRuntimeOwnedDormant
+ && !activation.Controller.IsDormantSetPositionGroundPhaseActive
+ && activation.Controller.OwnsPhysicsBody(activation.Body)
+ && _entities.SessionLifetimeVersion
+ == activation.Token.SessionGenerationAuthority
+ && _entities.IsCurrent(activation.Record)
+ && activation.Record.Key == activation.Token.Entity
+ && !_identity.IsDisposed
+ && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid
+ && _identity.ServerGuid == activation.Record.ServerGuid
+ && _identity.Revision == activation.Token.LocalPlayerIdentityRevision
+ && activation.Record.PhysicsOwnershipEpoch
+ == activation.Token.PhysicsOwnershipEpoch
+ && activation.Record.ObjectClockEpoch
+ == activation.Token.ObjectClockEpoch
+ && _movement.CanCommitRuntimeOwnedController(
+ activation.Token.ControllerOwnershipEpoch,
+ activation.Controller)
+ && ReferenceEquals(activation.Record.PhysicsBody, activation.Body)
+ && activation.Record.PhysicsHost is null
+ && activation.Record.RemoteMotion is null
+ && activation.Record.Projectile is null
+ && !activation.Record.PhysicsBodyAcquisitionInProgress
+ && !activation.Record.RemoteMotionBindingInProgress
+ && !activation.Record.ProjectileBindingInProgress
+ && !activation.Record.RequiresRemotePlacementRuntime
+ && !activation.Record.DeleteAcceptedForTeardown;
+
+ private static bool RefreshDormantState(Activation activation)
+ {
+ activation.Controller.RefreshDormantRuntimePhysicsState(
+ activation.Record.FinalPhysicsState,
+ recalculateAcceleration: false);
+ return true;
+ }
+
+ private static bool RefreshDormantVector(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (activation.Record.VectorAuthorityVersion
+ == receipt.SourceVectorAuthorityVersion)
+ return true;
+ var physics = activation.Record.Snapshot.Physics;
+ activation.Controller.RefreshDormantRuntimeVector(
+ physics?.Velocity,
+ physics?.AngularVelocity);
+ return true;
+ }
+
+ private void AbortActivation(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt receipt,
+ bool collisionAlreadyDispatched)
+ {
+ _physics.SetPosition.RetireDormantLocalActivation(
+ receipt,
+ collisionAlreadyDispatched);
+ activation.Receipt = default;
+ activation.PendingFinalCommit = default;
+ if (ReferenceEquals(_activation, activation))
+ DiscardActivation();
+ }
+
internal bool DiscardActivation(
in RuntimeLocalPlayerPhysicsActivationToken token)
{
@@ -389,6 +832,9 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
_activation is null ? 0 : 1,
_nextPublicationId);
+ internal long ActivationDispatchFailureCount =>
+ _activationDispatchFailureCount;
+
internal void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -501,15 +947,67 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
&& !activation.Record.ProjectileBindingInProgress
&& !activation.Record.RequiresRemotePlacementRuntime
&& !activation.Record.DeleteAcceptedForTeardown
- && _physics.SetPosition.IsExactPreparedPlacementCurrent(
+ && _physics.SetPosition.IsDormantLocalActivationLeaseCurrent(
activation.Record,
+ activation.Body,
activation.Token.Placement,
activation.PlacementCommand);
+ private bool IsCommittedActivationSuffixCurrent(
+ Activation activation,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ return activation.Token.ObjectClockEpoch != ulong.MaxValue
+ && _entities.SessionLifetimeVersion
+ == activation.Token.SessionGenerationAuthority
+ && _entities.IsCurrent(activation.Record)
+ && activation.Record.Key == activation.Token.Entity
+ && !_identity.IsDisposed
+ && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid
+ && _identity.ServerGuid == activation.Record.ServerGuid
+ && _identity.Revision
+ == activation.Token.LocalPlayerIdentityRevision
+ && activation.Record.PhysicsOwnershipEpoch
+ == activation.Token.PhysicsOwnershipEpoch
+ && activation.Record.ObjectClockEpoch
+ == activation.Token.ObjectClockEpoch + 1UL
+ && _movement.ControllerOwnershipEpoch
+ == activation.Token.ControllerOwnershipEpoch
+ && ReferenceEquals(_movement.Controller, activation.Controller)
+ && activation.Controller.IsRuntimePublished
+ && activation.Controller.OwnsPhysicsBody(activation.Body)
+ && ReferenceEquals(
+ activation.Record.PhysicsBody,
+ activation.Body)
+ && ReferenceEquals(
+ activation.Record.PhysicsHost,
+ activation.PhysicsHost)
+ && activation.Record.RemoteMotion is null
+ && activation.Record.Projectile is null
+ && !activation.Record.DeleteAcceptedForTeardown
+ && _physics.SetPosition.IsDormantLocalActivationCommitCurrent(
+ activation.Record,
+ activation.Body,
+ receipt);
+ }
+
private void DiscardActivation()
{
Activation? activation = _activation;
_activation = null;
+ if (activation is not null)
+ {
+ if (activation.PendingFinalCommit.Status
+ is not RuntimeDormantSetPositionCommitStatus.None)
+ {
+ _physics.SetPosition.RetireDormantLocalActivation(
+ activation.PendingFinalCommit,
+ collisionAlreadyDispatched: true);
+ }
+ _physics.SetPosition.RetireDormantLocalActivationToken(
+ activation.Record,
+ activation.Token.Placement);
+ }
if (activation is not null
&& _entities.IsCurrent(activation.Record)
&& ReferenceEquals(
diff --git a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs
index cbd61c95..b3dbc360 100644
--- a/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeCollisionReportingState.cs
@@ -11,6 +11,17 @@ internal enum RuntimeCollisionReportKind
EnvironmentCollision,
}
+internal enum SetPositionCollisionBatchDispatchStatus : byte
+{
+ RejectedReceipt,
+ Displaced,
+ Completed,
+}
+
+internal readonly record struct SetPositionCollisionBatchDispatchResult(
+ SetPositionCollisionBatchDispatchStatus Status,
+ bool Reported);
+
///
/// Immutable presentation-free projection of one retail weenie collision
/// callback. Runtime commits the callback before an observer can re-enter.
@@ -38,6 +49,7 @@ internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot(
int PendingReportCount,
int LeavingOwnerCount,
int AdmissionBlockedOwnerCount,
+ int PendingSetPositionDispatchCount,
bool IsDispatching,
long DispatchFailureCount,
bool IsDisposed)
@@ -51,6 +63,7 @@ internal readonly record struct RuntimeCollisionReportingOwnershipSnapshot(
&& PendingReportCount == 0
&& LeavingOwnerCount == 0
&& AdmissionBlockedOwnerCount == 0
+ && PendingSetPositionDispatchCount == 0
&& !IsDispatching;
}
@@ -78,6 +91,10 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
private ulong _nextSequence;
private ulong _dispatchEpoch = 1UL;
private long _dispatchFailureCount;
+ private ulong _mutationRevision;
+ private ulong _nextPreparedBatchId;
+ private ulong _lastInstalledBatchId;
+ private readonly HashSet _pendingSetPositionDispatches = [];
private bool _dispatching;
private bool _disposed;
@@ -102,6 +119,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
_pendingReports.Count,
_leaving.Count,
_admissionBlocked.Count,
+ _pendingSetPositionDispatches.Count,
_dispatching,
_dispatchFailureCount,
_disposed);
@@ -125,6 +143,574 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
return new Subscription(this, observer);
}
+ internal enum StagedReportEligibility : byte
+ {
+ Environment,
+ Object,
+ }
+
+ private sealed record FrozenCollisionSubject(
+ uint LocalEntityId,
+ bool IsStatic,
+ RuntimeEntityRecord? Record,
+ PhysicsBody? Body,
+ RuntimeEntityKey Key);
+
+ internal sealed record StagedReportAction(
+ StagedReportEligibility Eligibility,
+ RuntimeEntityRecord Recipient,
+ PhysicsBody RecipientBody,
+ RuntimeEntityKey RecipientKey,
+ RuntimeEntityRecord? Other,
+ PhysicsBody? OtherBody,
+ RuntimeEntityKey? OtherKey,
+ bool RecipientContact,
+ bool ExactDormantRecipient,
+ ulong RecipientPositionAuthorityVersion);
+
+ internal sealed class PreparedSetPositionCollisionBatch
+ {
+ internal required ulong BatchId { get; init; }
+ internal required ulong ExpectedMutationRevision { get; init; }
+ internal required ulong InstalledMutationRevision { get; init; }
+ internal required ulong SessionLifetimeVersion { get; init; }
+ internal required RuntimeEntityRecord Owner { get; init; }
+ internal required PhysicsBody OwnerBody { get; init; }
+ internal required RuntimeEntityKey OwnerKey { get; init; }
+ internal required ulong OwnerPositionAuthorityVersion { get; init; }
+ internal required OwnerState? OwnerState { get; init; }
+ internal required bool PreviousContact { get; init; }
+ internal required bool FinalCollidedWithEnvironment { get; init; }
+ internal required bool FinalGroundEdge { get; init; }
+ internal required double PhysicsTime { get; init; }
+ internal required StagedReportAction[] Actions { get; init; }
+ }
+
+ internal readonly record struct SetPositionCollisionBatchReceipt(
+ ulong BatchId,
+ RuntimeEntityRecord Owner,
+ PhysicsBody OwnerBody,
+ RuntimeEntityKey OwnerKey,
+ ulong OwnerPositionAuthorityVersion,
+ bool PreviousContact,
+ bool FinalCollidedWithEnvironment,
+ bool FinalGroundEdge,
+ double PhysicsTime,
+ StagedReportAction[] Actions)
+ {
+ internal bool IsValid => BatchId != 0UL;
+ }
+
+ ///
+ /// Freezes retail COLLISIONINFO subjects and prepares the owner-table,
+ /// environment-latch, and callback subjects without mutating Runtime.
+ /// Dynamic tracking and reverse-index writes occur during ordered dispatch
+ /// after each subject's live behavior flags are revalidated. Callback
+ /// eligibility is evaluated after the dormant
+ /// frame/contact/ground prephase and before physical response, shadow
+ /// publication, and enter-world activation, matching retail SetPosition.
+ ///
+ internal bool TryPrepareSetPositionBatch(
+ RuntimeEntityRecord owner,
+ PhysicsBody ownerBody,
+ double physicsTime,
+ bool previousContact,
+ bool previousOnWalkable,
+ bool finalOnWalkable,
+ bool collidedWithEnvironment,
+ ImmutableArray collidedObjectIds,
+ out PreparedSetPositionCollisionBatch? prepared)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(owner);
+ ArgumentNullException.ThrowIfNull(ownerBody);
+ prepared = null;
+ RuntimeEntityKey ownerKey = owner.Key ?? default;
+ if (!double.IsFinite(physicsTime)
+ || ownerKey == default
+ || !IsKnownParticipant(owner, ownerBody, ownerKey)
+ || _leaving.Contains(ownerKey)
+ || _admissionBlocked.Contains(ownerKey))
+ {
+ return false;
+ }
+
+ ulong expectedMutation = _mutationRevision;
+ ulong installedMutation = checked(expectedMutation + 1UL);
+ ulong sessionLifetime = _entities.SessionLifetimeVersion;
+ if (collidedObjectIds.IsDefault)
+ collidedObjectIds = ImmutableArray.Empty;
+ var subjects = new List(
+ collidedObjectIds.Length);
+ for (int index = 0; index < collidedObjectIds.Length; index++)
+ {
+ uint localId = collidedObjectIds[index];
+ if (localId == 0u || localId == ownerKey.LocalEntityId)
+ continue;
+ if (!_shadows.TryGetCollisionOwner(
+ localId,
+ out uint shadowState,
+ out bool isStatic))
+ {
+ continue;
+ }
+ if (isStatic)
+ {
+ subjects.Add(new FrozenCollisionSubject(
+ localId,
+ IsStatic: true,
+ Record: null,
+ Body: null,
+ Key: default));
+ continue;
+ }
+ if (!_entities.TryGetByLocalId(localId, out RuntimeEntityRecord target)
+ || target.PhysicsBody is not { } targetBody
+ || !_entities.IsCurrent(target)
+ || target.Key is not { } targetKey)
+ {
+ continue;
+ }
+ subjects.Add(new FrozenCollisionSubject(
+ localId,
+ IsStatic: false,
+ target,
+ targetBody,
+ targetKey));
+ _ = shadowState;
+ }
+
+ OwnerState staged = CloneOwnerState(TryGetOwner(ownerKey));
+ var actions = new List(subjects.Count);
+ void StageEnvironment()
+ {
+ actions.Add(new StagedReportAction(
+ StagedReportEligibility.Environment,
+ owner,
+ ownerBody,
+ ownerKey,
+ Other: null,
+ OtherBody: null,
+ OtherKey: null,
+ RecipientContact: previousContact,
+ ExactDormantRecipient: !ownerBody.InWorld,
+ RecipientPositionAuthorityVersion:
+ owner.PositionAuthorityVersion));
+ }
+ for (int index = 0; index < subjects.Count; index++)
+ {
+ FrozenCollisionSubject subject = subjects[index];
+ if (subject.IsStatic)
+ {
+ StageEnvironment();
+ continue;
+ }
+ RuntimeEntityRecord target = subject.Record!;
+ PhysicsBody targetBody = subject.Body!;
+ RuntimeEntityKey targetKey = subject.Key;
+ // Dynamic classification and behavior flags are evaluated after
+ // the ground edge. Retain the old record unchanged in the
+ // installed batch; the ordered tracking action below performs
+ // retail's timestamp/Ethereal clobber or Static conversion.
+ actions.Add(new StagedReportAction(
+ StagedReportEligibility.Object,
+ owner,
+ ownerBody,
+ ownerKey,
+ target,
+ targetBody,
+ targetKey,
+ RecipientContact: previousContact,
+ ExactDormantRecipient: !ownerBody.InWorld,
+ RecipientPositionAuthorityVersion:
+ owner.PositionAuthorityVersion));
+ }
+
+ _owners.EnsureCapacity(_owners.Count + 1);
+ _pendingSetPositionDispatches.EnsureCapacity(
+ _pendingSetPositionDispatches.Count + 1);
+ prepared = new PreparedSetPositionCollisionBatch
+ {
+ BatchId = checked(++_nextPreparedBatchId),
+ ExpectedMutationRevision = expectedMutation,
+ InstalledMutationRevision = installedMutation,
+ SessionLifetimeVersion = sessionLifetime,
+ Owner = owner,
+ OwnerBody = ownerBody,
+ OwnerKey = ownerKey,
+ OwnerPositionAuthorityVersion = owner.PositionAuthorityVersion,
+ OwnerState = staged.Records.Count == 0
+ && !staged.CollidingWithEnvironment
+ ? null
+ : staged,
+ PreviousContact = previousContact,
+ FinalCollidedWithEnvironment = collidedWithEnvironment,
+ FinalGroundEdge = !previousOnWalkable && finalOnWalkable,
+ PhysicsTime = physicsTime,
+ Actions = actions.ToArray(),
+ };
+ _ = previousContact;
+ return _mutationRevision == expectedMutation
+ && _entities.SessionLifetimeVersion == sessionLifetime
+ && IsKnownParticipant(owner, ownerBody, ownerKey);
+ }
+
+ internal bool TryInstallSetPositionBatch(
+ PreparedSetPositionCollisionBatch prepared,
+ out SetPositionCollisionBatchReceipt receipt)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(prepared);
+ receipt = default;
+ if (prepared.BatchId <= _lastInstalledBatchId
+ || _mutationRevision != prepared.ExpectedMutationRevision
+ || _entities.SessionLifetimeVersion
+ != prepared.SessionLifetimeVersion
+ || !IsKnownParticipant(
+ prepared.Owner,
+ prepared.OwnerBody,
+ prepared.OwnerKey))
+ {
+ return false;
+ }
+ if (!IsKnownParticipant(
+ prepared.Owner,
+ prepared.OwnerBody,
+ prepared.OwnerKey))
+ return false;
+
+ if (prepared.OwnerState is null)
+ _owners.Remove(prepared.OwnerKey);
+ else
+ {
+ prepared.OwnerState.SetPositionBatchId = prepared.BatchId;
+ _owners[prepared.OwnerKey] = prepared.OwnerState;
+ }
+ _mutationRevision = prepared.InstalledMutationRevision;
+ _lastInstalledBatchId = prepared.BatchId;
+ _pendingSetPositionDispatches.Add(prepared.BatchId);
+ receipt = new SetPositionCollisionBatchReceipt(
+ prepared.BatchId,
+ prepared.Owner,
+ prepared.OwnerBody,
+ prepared.OwnerKey,
+ prepared.OwnerPositionAuthorityVersion,
+ prepared.PreviousContact,
+ prepared.FinalCollidedWithEnvironment,
+ prepared.FinalGroundEdge,
+ prepared.PhysicsTime,
+ prepared.Actions);
+ return true;
+ }
+
+ internal bool IsPreparedSetPositionBatchCurrent(
+ PreparedSetPositionCollisionBatch prepared)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+ return !_disposed
+ && prepared.BatchId > _lastInstalledBatchId
+ && _mutationRevision == prepared.ExpectedMutationRevision
+ && _entities.SessionLifetimeVersion
+ == prepared.SessionLifetimeVersion
+ && IsKnownParticipant(
+ prepared.Owner,
+ prepared.OwnerBody,
+ prepared.OwnerKey);
+ }
+
+ internal bool DispatchSetPositionBatch(
+ in SetPositionCollisionBatchReceipt receipt) =>
+ DispatchSetPositionBatchResult(receipt).Reported;
+
+ internal SetPositionCollisionBatchDispatchResult
+ DispatchSetPositionBatchResult(
+ in SetPositionCollisionBatchReceipt receipt)
+ {
+ if (!receipt.IsValid
+ || _disposed
+ || receipt.BatchId > _lastInstalledBatchId
+ || !_pendingSetPositionDispatches.Remove(receipt.BatchId))
+ {
+ return new(
+ SetPositionCollisionBatchDispatchStatus.RejectedReceipt,
+ Reported: false);
+ }
+ bool reported = false;
+ for (int index = 0; index < receipt.Actions.Length; index++)
+ {
+ if (receipt.Owner.PositionAuthorityVersion
+ != receipt.OwnerPositionAuthorityVersion
+ || _owners.TryGetValue(
+ receipt.OwnerKey, out OwnerState? currentOwner)
+ && currentOwner.SetPositionBatchId != receipt.BatchId)
+ {
+ return new(
+ SetPositionCollisionBatchDispatchStatus.Displaced,
+ reported);
+ }
+ StagedReportAction action = receipt.Actions[index];
+ if (action.Eligibility is StagedReportEligibility.Environment)
+ {
+ reported |= DispatchEnvironmentAction(
+ action.Recipient,
+ action.RecipientBody,
+ action.RecipientKey,
+ action.RecipientContact,
+ receipt.BatchId);
+ continue;
+ }
+ reported |= DispatchTrackingAction(
+ action, receipt.PhysicsTime, receipt.BatchId);
+ }
+
+ if (receipt.Owner.PositionAuthorityVersion
+ != receipt.OwnerPositionAuthorityVersion
+ || _owners.TryGetValue(
+ receipt.OwnerKey, out OwnerState? suffixOwner)
+ && suffixOwner.SetPositionBatchId != receipt.BatchId)
+ {
+ return new(
+ SetPositionCollisionBatchDispatchStatus.Displaced,
+ reported);
+ }
+
+ // Retail chooses the expired set only after every current collision
+ // has either refreshed its live Ethereal bit/timestamp or converted
+ // to environment. EndExpiredObjectCollisions predeletes the complete
+ // selected suffix before its first callback.
+ EndExpiredObjectCollisions(
+ receipt.Owner,
+ receipt.OwnerBody,
+ receipt.OwnerKey,
+ receipt.PhysicsTime,
+ force: false,
+ receipt.BatchId,
+ receipt.OwnerPositionAuthorityVersion);
+ if (!IsSetPositionBatchOwnerCurrent(receipt))
+ {
+ return new(
+ SetPositionCollisionBatchDispatchStatus.Displaced,
+ reported);
+ }
+ reported |= DispatchEnvironmentSuffix(receipt);
+ return new(
+ SetPositionCollisionBatchDispatchStatus.Completed,
+ reported);
+ }
+
+ internal bool DiscardSetPositionBatch(
+ in SetPositionCollisionBatchReceipt receipt) =>
+ receipt.IsValid
+ && _pendingSetPositionDispatches.Remove(receipt.BatchId);
+
+ internal void RetireSetPositionBatchOwner(
+ in SetPositionCollisionBatchReceipt receipt)
+ {
+ if (!receipt.IsValid || _disposed)
+ return;
+ _pendingSetPositionDispatches.Remove(receipt.BatchId);
+ if (!IsKnownParticipant(
+ receipt.Owner,
+ receipt.OwnerBody,
+ receipt.OwnerKey)
+ || !_owners.TryGetValue(
+ receipt.OwnerKey, out OwnerState? owner)
+ || owner.SetPositionBatchId != receipt.BatchId)
+ {
+ return;
+ }
+ _mutationRevision = checked(_mutationRevision + 1UL);
+ if (!_admissionBlocked.Add(receipt.OwnerKey))
+ return;
+ try
+ {
+ ForceEnd(receipt.Owner, receipt.OwnerKey);
+ if (_owners.TryGetValue(receipt.OwnerKey, out OwnerState? current)
+ && ReferenceEquals(current, owner)
+ && current.SetPositionBatchId == receipt.BatchId)
+ {
+ current.CollidingWithEnvironment = false;
+ _owners.Remove(receipt.OwnerKey);
+ }
+ }
+ finally
+ {
+ _admissionBlocked.Remove(receipt.OwnerKey);
+ }
+ }
+
+ private bool DispatchTrackingAction(
+ StagedReportAction action,
+ double physicsTime,
+ ulong batchId)
+ {
+ if (action.Other is null
+ || action.OtherBody is null
+ || action.OtherKey is not { } targetKey
+ || !IsKnownParticipant(
+ action.Recipient,
+ action.RecipientBody,
+ action.RecipientKey)
+ || !IsKnownParticipant(
+ action.Other,
+ action.OtherBody,
+ targetKey))
+ {
+ return false;
+ }
+
+ PhysicsStateFlags targetState = action.OtherBody.State;
+ if ((targetState & PhysicsStateFlags.Static) != 0)
+ {
+ // track_object_collision is skipped entirely on this path. Any
+ // older record therefore retains its old timestamp and remains
+ // eligible for the immediately-following expiry pass.
+ return DispatchEnvironmentAction(
+ action.Recipient,
+ action.RecipientBody,
+ action.RecipientKey,
+ action.RecipientContact,
+ batchId);
+ }
+
+ OwnerState state = GetOrCreateOwner(action.RecipientKey, batchId);
+ bool isNew = !state.Records.ContainsKey(targetKey);
+ state.Records[targetKey] = new CollisionRecord(
+ physicsTime,
+ (targetState & PhysicsStateFlags.Ethereal) != 0,
+ action.Other.ServerGuid);
+ if (!isNew)
+ {
+ _mutationRevision = checked(_mutationRevision + 1UL);
+ return false;
+ }
+
+ state.Order.Add(targetKey);
+ AddReverseOwner(targetKey, action.RecipientKey);
+ _mutationRevision = checked(_mutationRevision + 1UL);
+ return ReportObject(
+ action.Recipient,
+ action.RecipientBody,
+ action.RecipientKey,
+ action.Other,
+ action.OtherBody,
+ targetKey,
+ targetState,
+ action.RecipientContact,
+ action.ExactDormantRecipient,
+ action.RecipientPositionAuthorityVersion,
+ batchId);
+ }
+
+ private bool DispatchEnvironmentAction(
+ RuntimeEntityRecord owner,
+ PhysicsBody ownerBody,
+ RuntimeEntityKey ownerKey,
+ bool previousContact,
+ ulong batchId)
+ {
+ if (!IsKnownParticipant(owner, ownerBody, ownerKey))
+ return false;
+ OwnerState state = GetOrCreateOwner(ownerKey, batchId);
+ if (state.CollidingWithEnvironment)
+ return false;
+
+ state.CollidingWithEnvironment = true;
+ _mutationRevision = checked(_mutationRevision + 1UL);
+ bool reported = (ownerBody.State
+ & PhysicsStateFlags.ReportCollisions) != 0;
+ if (reported)
+ {
+ Publish(new RuntimeCollisionReport(
+ NextSequence(),
+ RuntimeCollisionReportKind.EnvironmentCollision,
+ ownerKey,
+ owner.ServerGuid,
+ Other: null,
+ OtherServerGuid: null,
+ previousContact,
+ OtherWasInContact: false));
+ }
+
+ // Environment collision tests Missile after the callback returns.
+ StopMissileForStagedOwner(
+ owner,
+ ownerBody,
+ ownerKey,
+ requireCurrentMissile: true);
+ return reported;
+ }
+
+ private bool DispatchEnvironmentSuffix(
+ in SetPositionCollisionBatchReceipt receipt)
+ {
+ if (!IsSetPositionBatchOwnerCurrent(receipt)
+ || !IsKnownParticipant(
+ receipt.Owner,
+ receipt.OwnerBody,
+ receipt.OwnerKey))
+ {
+ return false;
+ }
+
+ OwnerState? state = TryGetOwner(receipt.OwnerKey);
+ if (state?.CollidingWithEnvironment == true)
+ {
+ if (state.CollidingWithEnvironment
+ != receipt.FinalCollidedWithEnvironment)
+ {
+ state.CollidingWithEnvironment =
+ receipt.FinalCollidedWithEnvironment;
+ _mutationRevision = checked(_mutationRevision + 1UL);
+ }
+ }
+ else if (receipt.FinalCollidedWithEnvironment
+ || receipt.FinalGroundEdge)
+ {
+ bool reported = DispatchEnvironmentAction(
+ receipt.Owner,
+ receipt.OwnerBody,
+ receipt.OwnerKey,
+ receipt.PreviousContact,
+ receipt.BatchId);
+ TrimEmptyOwner(receipt.OwnerKey);
+ return reported;
+ }
+ TrimEmptyOwner(receipt.OwnerKey);
+ return false;
+ }
+
+ private void StopMissileForStagedOwner(
+ RuntimeEntityRecord owner,
+ PhysicsBody ownerBody,
+ RuntimeEntityKey ownerKey,
+ bool requireCurrentMissile)
+ {
+ if (!IsKnownParticipant(owner, ownerBody, ownerKey)
+ || !_entities.StopMissileAfterCollision(
+ owner,
+ requireCurrentMissile))
+ {
+ return;
+ }
+ _shadows.UpdatePhysicsState(
+ ownerKey.LocalEntityId,
+ (uint)owner.FinalPhysicsState);
+ }
+
+ private static OwnerState CloneOwnerState(OwnerState? source)
+ {
+ var clone = new OwnerState();
+ if (source is null)
+ return clone;
+ foreach ((RuntimeEntityKey key, CollisionRecord record)
+ in source.Records)
+ clone.Records.Add(key, record);
+ clone.Order.AddRange(source.Order);
+ clone.CollidingWithEnvironment = source.CollidingWithEnvironment;
+ return clone;
+ }
+
///
/// Ports the reporting/tracking portion of retail
/// CPhysicsObj::handle_all_collisions (0x00514780). The return is
@@ -148,6 +734,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
{
return false;
}
+ _mutationRevision = checked(_mutationRevision + 1UL);
bool reported = false;
if (collidedObjectIds.IsDefault)
@@ -266,6 +853,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
return;
+ _mutationRevision = checked(_mutationRevision + 1UL);
if (!_admissionBlocked.Add(key))
return;
try
@@ -288,6 +876,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(records);
+ _mutationRevision = checked(_mutationRevision + 1UL);
var blocked = new List<(RuntimeEntityRecord Record, RuntimeEntityKey Key)>(
records.Count);
for (int index = 0; index < records.Count; index++)
@@ -322,6 +911,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
ArgumentNullException.ThrowIfNull(record);
if (record.Key is not { } key)
return;
+ _mutationRevision = checked(_mutationRevision + 1UL);
// A session-clear batch blocks every owner before publishing the
// first force-end callback. A callback may synchronously accept the
// deletion of a later, already-blocked owner. That owner must still
@@ -342,11 +932,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
internal void ResetSession()
{
EnsureNotDisposed();
+ _mutationRevision = checked(_mutationRevision + 1UL);
_owners.Clear();
_ownersByPeer.Clear();
_pendingReports.Clear();
_leaving.Clear();
_admissionBlocked.Clear();
+ _pendingSetPositionDispatches.Clear();
_dispatchEpoch = checked(_dispatchEpoch + 1UL);
}
@@ -359,6 +951,7 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
_pendingReports.Clear();
_leaving.Clear();
_admissionBlocked.Clear();
+ _pendingSetPositionDispatches.Clear();
_observers = [];
_dispatchEpoch = checked(_dispatchEpoch + 1UL);
_disposed = true;
@@ -372,7 +965,10 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
PhysicsBody targetBody,
RuntimeEntityKey targetKey,
PhysicsStateFlags targetState,
- bool previousContact)
+ bool previousContact,
+ bool exactDormantOwner = false,
+ ulong expectedOwnerPositionAuthorityVersion = 0UL,
+ ulong setPositionBatchId = 0UL)
{
if ((targetState & PhysicsStateFlags.ReportAsEnvironment) != 0)
{
@@ -380,7 +976,8 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
owner,
ownerBody,
ownerKey,
- previousContact);
+ previousContact,
+ setPositionBatchId);
}
PhysicsStateFlags ownerState = ownerBody.State;
@@ -416,7 +1013,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
// reentrant state update can therefore suppress this second report.
bool targetReported = IsCurrentParticipant(target, targetBody, targetKey)
&& (targetBody.State & PhysicsStateFlags.ReportCollisions) != 0
- && IsCurrentParticipant(owner, ownerBody, ownerKey)
+ && (IsCurrentParticipant(owner, ownerBody, ownerKey)
+ || exactDormantOwner
+ && IsExactDormantParticipant(
+ owner,
+ ownerBody,
+ ownerKey,
+ expectedOwnerPositionAuthorityVersion))
&& (ownerBody.State & PhysicsStateFlags.IgnoreCollisions) == 0;
if (targetReported)
{
@@ -433,13 +1036,31 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
return ownerReported || targetReported;
}
+ private bool IsExactDormantParticipant(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ RuntimeEntityKey key,
+ ulong expectedPositionAuthorityVersion) =>
+ !body.InWorld
+ && (body.TransientState & TransientStateFlags.Active) == 0
+ && (body.State & PhysicsStateFlags.Hidden) == 0
+ && _entities.IsCurrent(record)
+ && !_leaving.Contains(key)
+ && !_admissionBlocked.Contains(key)
+ && expectedPositionAuthorityVersion != 0UL
+ && record.PositionAuthorityVersion
+ == expectedPositionAuthorityVersion
+ && IsKnownParticipant(record, body, key);
+
private bool ReportEnvironment(
RuntimeEntityRecord owner,
PhysicsBody ownerBody,
RuntimeEntityKey ownerKey,
- bool previousContact)
+ bool previousContact,
+ ulong setPositionBatchId = 0UL)
{
- OwnerState state = GetOrCreateOwner(ownerKey);
+ OwnerState state = GetOrCreateOwner(
+ ownerKey, setPositionBatchId);
if (state.CollidingWithEnvironment)
return false;
@@ -467,7 +1088,9 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
PhysicsBody? ownerBody,
RuntimeEntityKey ownerKey,
double physicsTime,
- bool force)
+ bool force,
+ ulong setPositionBatchId = 0UL,
+ ulong expectedPositionAuthorityVersion = 0UL)
{
if (!_owners.TryGetValue(ownerKey, out OwnerState? state)
|| state.Records.Count == 0)
@@ -524,7 +1147,15 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
|| reportEpoch != _dispatchEpoch
|| _entities.SessionLifetimeVersion != sourceSessionVersion
|| _entities.CurrentLifetimeMutation(owner.ServerGuid)
- != sourceLifetimeMutation)
+ != sourceLifetimeMutation
+ || setPositionBatchId != 0UL
+ && (owner.PositionAuthorityVersion
+ != expectedPositionAuthorityVersion
+ || !_owners.TryGetValue(
+ ownerKey, out OwnerState? currentOwner)
+ || !ReferenceEquals(currentOwner, state)
+ || currentOwner.SetPositionBatchId
+ != setPositionBatchId))
{
break;
}
@@ -563,6 +1194,13 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
TrimEmptyOwner(ownerKey);
}
+ private bool IsSetPositionBatchOwnerCurrent(
+ in SetPositionCollisionBatchReceipt receipt) =>
+ receipt.Owner.PositionAuthorityVersion
+ == receipt.OwnerPositionAuthorityVersion
+ && (!_owners.TryGetValue(receipt.OwnerKey, out OwnerState? owner)
+ || owner.SetPositionBatchId == receipt.BatchId);
+
private void PublishResolvedObjectEnd(
RuntimeEntityRecord owner,
PhysicsBody? ownerBody,
@@ -654,13 +1292,16 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
(uint)owner.FinalPhysicsState);
}
- private OwnerState GetOrCreateOwner(RuntimeEntityKey key)
+ private OwnerState GetOrCreateOwner(
+ RuntimeEntityKey key,
+ ulong setPositionBatchId = 0UL)
{
if (!_owners.TryGetValue(key, out OwnerState? owner))
{
owner = new OwnerState();
_owners.Add(key, owner);
}
+ owner.SetPositionBatchId = setPositionBatchId;
return owner;
}
@@ -874,15 +1515,16 @@ internal sealed class RuntimeCollisionReportingState : IDisposable
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
- private sealed class OwnerState
+ internal sealed class OwnerState
{
internal Dictionary Records { get; }
= new();
internal List Order { get; } = [];
internal bool CollidingWithEnvironment { get; set; }
+ internal ulong SetPositionBatchId { get; set; }
}
- private readonly record struct CollisionRecord(
+ internal readonly record struct CollisionRecord(
double TouchedTime,
bool Ethereal,
uint ServerGuid);
diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
index 285566dd..67fd2f26 100644
--- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
@@ -32,6 +32,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
int PendingCollisionReportCount,
int LeavingCollisionReportOwnerCount,
int CollisionReportAdmissionBlockedOwnerCount,
+ int PendingCollisionSetPositionDispatchCount,
+ int PendingShadowSetPositionDispatchCount,
bool IsCollisionReportDispatching,
int CollisionAdmissionCount,
int CollisionGenerationCount,
@@ -66,6 +68,8 @@ public readonly record struct RuntimePhysicsOwnershipSnapshot(
&& PendingCollisionReportCount == 0
&& LeavingCollisionReportOwnerCount == 0
&& CollisionReportAdmissionBlockedOwnerCount == 0
+ && PendingCollisionSetPositionDispatchCount == 0
+ && PendingShadowSetPositionDispatchCount == 0
&& !IsCollisionReportDispatching
&& CollisionAdmissionCount == 0
&& CollisionGenerationCount == 0
@@ -1162,6 +1166,8 @@ public sealed class RuntimePhysicsState : IDisposable
collisionReports.PendingReportCount,
collisionReports.LeavingOwnerCount,
collisionReports.AdmissionBlockedOwnerCount,
+ collisionReports.PendingSetPositionDispatchCount,
+ Engine.ShadowObjects.PendingSetPositionDispatchCount,
collisionReports.IsDispatching,
_collisionAdmissions.Count,
_collisionGenerations.Count,
@@ -2529,6 +2535,18 @@ public sealed class RuntimePhysicsState : IDisposable
TrimCollisionOwnerJournal();
}
+ internal bool TryPrepareSpatialRootAdmission(RuntimeEntityRecord record)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(record);
+ if (record.Key is null || !Entities.IsCurrent(record))
+ return false;
+ _spatialRoots.EnsureCapacity(_spatialRoots.Count + 1);
+ _spatialRemotes.EnsureCapacity(_spatialRemotes.Count + 1);
+ _spatialProjectiles.EnsureCapacity(_spatialProjectiles.Count + 1);
+ return Entities.IsCurrent(record);
+ }
+
internal ulong ExpectedCollisionGeneration(uint exactCellId)
{
uint landblockId = CanonicalLandblock(exactCellId);
@@ -2671,6 +2689,32 @@ public sealed class RuntimePhysicsState : IDisposable
return true;
}
+ internal bool IsCollisionEvaluationFatalAuthorityCurrent(
+ in RuntimeCollisionEvaluationAuthority authority)
+ {
+ if (!authority.IsValid
+ || _collisionWorldAuthority != authority.CollisionWorldAuthority
+ || !ReferenceEquals(ObjectTable, authority.ObjectTable)
+ || ObjectTableBindingAuthority
+ != authority.ObjectTableBindingAuthority
+ || ObjectTableAuthority != authority.ObjectTableAuthority)
+ {
+ return false;
+ }
+ foreach (RuntimeCollisionGenerationAuthority generation
+ in authority.Generations)
+ {
+ if (generation.LandblockId == 0u
+ || _collisionAdmissions.ContainsKey(generation.LandblockId)
+ || CollisionGenerationAuthority(generation.LandblockId)
+ != generation.Generation)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
internal bool HandleSetPositionCollisions(
RuntimeEntityRecord record,
ulong positionAuthorityVersion,
diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
index b20e2070..dbe197f2 100644
--- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
@@ -4,6 +4,7 @@ using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
+using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Physics;
@@ -29,6 +30,7 @@ internal enum RuntimeEntityPlacementStage
AwaitingPreparation,
AwaitingWithdrawalAcknowledgement,
AwaitingCell,
+ AwaitingFinalShadowPreparation,
AwaitingCommitAcknowledgement,
CancelledAwaitingAcknowledgement,
}
@@ -109,6 +111,65 @@ internal readonly record struct RuntimeDormantSetPositionEvaluation(
&& CollisionAuthority.IsValid;
}
+internal enum RuntimeDormantSetPositionCommitStatus : byte
+{
+ None,
+ AwaitingFinalShadowPreparation,
+ Committed,
+ DeferredCell,
+ RejectedPlacement,
+ RejectedAuthority,
+}
+
+internal sealed class PreparedDormantSetPositionCommit
+{
+ internal required RuntimeDormantSetPositionEvaluation Evaluation
+ { get; init; }
+ internal required RuntimeEntityKey Entity { get; init; }
+ internal required ulong OperationId { get; init; }
+ internal required ulong ExpectedProjectionSequence { get; init; }
+ internal required ShadowObjectRegistry.PreparedSetPositionShadowCommit?
+ Shadow { get; init; }
+ internal required RuntimeCollisionReportingState
+ .PreparedSetPositionCollisionBatch? Collision { get; init; }
+ internal required RuntimePlacementProjectionSnapshot Projection
+ { get; init; }
+ internal required SortedDictionary?
+ PendingProjection { get; init; }
+ internal required ulong DeferredCollisionGeneration { get; init; }
+ internal required List? DeferredBucket { get; init; }
+ internal required bool DeferredBucketIsNew { get; init; }
+}
+
+internal sealed class PreparedDormantActivationFinalCommit
+{
+ internal required RuntimeEntityKey Entity { get; init; }
+ internal required ulong OperationId { get; init; }
+ internal required ulong ExpectedProjectionSequence { get; init; }
+ internal required ShadowObjectRegistry.PreparedSetPositionShadowCommit
+ Shadow { get; init; }
+ internal required RuntimePlacementProjectionSnapshot Projection
+ { get; init; }
+ internal required SortedDictionary
+ PendingProjection { get; init; }
+}
+
+internal readonly record struct RuntimeDormantSetPositionCommitReceipt(
+ RuntimeDormantSetPositionCommitStatus Status,
+ RuntimeEntityKey Entity,
+ ulong OperationId,
+ RuntimePlacementProjectionSnapshot Projection,
+ RuntimeCollisionReportingState.SetPositionCollisionBatchReceipt Collision,
+ ShadowObjectRegistry.SetPositionShadowCommitReceipt Shadow,
+ RuntimeCollisionEvaluationAuthority CollisionAuthority,
+ ulong SourceVectorAuthorityVersion,
+ bool HitGround,
+ bool LeaveGround)
+{
+ internal bool IsCommitted => Status
+ is RuntimeDormantSetPositionCommitStatus.Committed;
+}
+
public readonly record struct RuntimePlacementProjectionToken(
ulong Sequence,
ulong Revision,
@@ -258,6 +319,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
internal List? LostFamilyKeys { get; set; }
internal bool InheritedLostDeadline { get; set; }
internal bool EnteringWorldFromCelllessResidence { get; set; }
+ internal bool DormantLocalActivation { get; set; }
internal RuntimeSetPositionCommand? PreparedCommandAwaitingWithdrawalAck
{
get;
@@ -289,7 +351,7 @@ internal sealed class RuntimeSetPositionState : IDisposable
private readonly Dictionary _operations = [];
private readonly Dictionary>
_deferredByCellGeneration = [];
- private readonly SortedDictionary
+ private SortedDictionary
_pendingProjection = [];
private readonly List _deferredBucketOrder = [];
private readonly Dictionary>
@@ -558,8 +620,12 @@ internal sealed class RuntimeSetPositionState : IDisposable
if (!token.IsValid
|| !_operations.TryGetValue(token.Entity, out Operation? operation)
|| operation.Token != token
- || operation.Stage
- is not RuntimeEntityPlacementStage.AwaitingPreparation
+ || operation.Stage is not (
+ RuntimeEntityPlacementStage.AwaitingPreparation
+ or RuntimeEntityPlacementStage.AwaitingCell)
+ || operation.Stage is RuntimeEntityPlacementStage.AwaitingCell
+ && (!operation.DormantLocalActivation
+ || !operation.WakeableLostCell)
|| !IsCurrent(operation)
|| !_moverPreparationAuthorities.TryGetValue(
token.Entity,
@@ -641,6 +707,20 @@ internal sealed class RuntimeSetPositionState : IDisposable
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(body);
evaluation = default;
+ if (!IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out _)
+ && !TryRearmDeferredDormantLocalActivation(
+ record,
+ body,
+ token,
+ command))
+ {
+ return false;
+ }
if (!IsExactDormantLocalActivationCurrent(
record,
body,
@@ -716,6 +796,779 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& _physics.IsCollisionEvaluationAuthorityCurrent(
evaluation.CollisionAuthority);
+ internal bool IsDormantLocalActivationLeaseCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command) =>
+ IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out _,
+ allowDeferredLease: true);
+
+ internal bool IsDormantLocalActivationAwaitingCell(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command)
+ {
+ return IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out Operation? operation,
+ allowDeferredLease: true)
+ && operation is not null
+ && operation.Stage is RuntimeEntityPlacementStage.AwaitingCell
+ && operation.DormantLocalActivation
+ && operation.WakeableLostCell;
+ }
+
+ private bool TryRearmDeferredDormantLocalActivation(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command)
+ {
+ if (!IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out Operation? operation,
+ allowDeferredLease: true)
+ || operation is null
+ || operation.Stage is not RuntimeEntityPlacementStage.AwaitingCell
+ || !operation.DormantLocalActivation
+ || !operation.WakeableLostCell
+ || !operation.CollisionGenerationReady
+ || operation.ProjectionSequence != 0UL
+ || operation.CollisionGeneration != _physics
+ .ExpectedCollisionGeneration(operation.ExactCellId)
+ || !_physics.Engine.IsSpawnCellReady(operation.ExactCellId))
+ {
+ return false;
+ }
+
+ UnindexDeferred(operation);
+ operation.WakeableLostCell = false;
+ operation.CollisionGenerationReady = false;
+ operation.Stage = RuntimeEntityPlacementStage.AwaitingPreparation;
+ return true;
+ }
+
+ internal bool TryPrepareDormantLocalActivationCommit(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionEvaluation evaluation,
+ bool provenShapeless,
+ out PreparedDormantSetPositionCommit? prepared)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentNullException.ThrowIfNull(body);
+ prepared = null;
+ if (!IsDormantLocalEvaluationCurrent(record, body, evaluation)
+ || !IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ evaluation.Placement,
+ evaluation.Command,
+ out Operation? operation,
+ allowCanonicalCommand: true)
+ || operation is null)
+ {
+ return false;
+ }
+
+ PhysicsSetPositionResult result = evaluation.Result;
+ ShadowObjectRegistry.PreparedSetPositionShadowCommit? shadow = null;
+ RuntimeCollisionReportingState.PreparedSetPositionCollisionBatch?
+ collision = null;
+ RuntimePlacementProjectionSnapshot projection = default;
+ SortedDictionary?
+ pendingProjection = null;
+ ulong deferredCollisionGeneration = 0UL;
+ List? deferredBucket = null;
+ bool deferredBucketIsNew = false;
+
+ if (!result.IsDeferred)
+ {
+ if (!_physics.CollisionReports.TryPrepareSetPositionBatch(
+ record,
+ body,
+ evaluation.Command.GameTime,
+ result.IsCommitted && operation.PreviousContact,
+ result.IsCommitted && operation.PreviousOnWalkable,
+ result.IsCommitted && result.OnWalkable,
+ result.CollidedWithEnvironment,
+ result.CollidedObjectIds,
+ out collision)
+ || collision is null)
+ {
+ return false;
+ }
+ }
+
+ if (result.IsDeferred)
+ {
+ deferredCollisionGeneration = _physics
+ .ExpectedCollisionGeneration(result.CellId);
+ _preparedMovers.EnsureCapacity(_preparedMovers.Count + 1);
+ if (result.CellId != 0u && deferredCollisionGeneration != 0UL)
+ {
+ var bucketKey = new CellGenerationKey(
+ result.CellId,
+ deferredCollisionGeneration);
+ if (_deferredByCellGeneration.TryGetValue(
+ bucketKey,
+ out deferredBucket))
+ {
+ deferredBucket.EnsureCapacity(deferredBucket.Count + 1);
+ }
+ else
+ {
+ deferredBucket = [operation.Key];
+ deferredBucketIsNew = true;
+ _deferredByCellGeneration.EnsureCapacity(
+ _deferredByCellGeneration.Count + 1);
+ _deferredBucketOrder.EnsureCapacity(
+ _deferredBucketOrder.Count + 1);
+ }
+ }
+ if (!_physics.Engine.ShadowObjects.TryPrepareSetPosition(
+ operation.Key.LocalEntityId,
+ result.Position,
+ result.Orientation,
+ result.CellId,
+ evaluation.Command.ShadowWorldOffsetX,
+ evaluation.Command.ShadowWorldOffsetY,
+ PhysicsShadowCommitAction.Preserve,
+ ImmutableArray.Empty,
+ provenShapeless,
+ suspendOwner: true,
+ out shadow)
+ || shadow is null)
+ {
+ return false;
+ }
+ }
+
+ ulong expectedProjectionSequence = _nextProjectionSequence;
+ if (result.IsCommitted)
+ {
+ _ = checked(record.ObjectClockEpoch + 1UL);
+ _ = checked(record.PlacementCommitVersion + 1UL);
+ if (record.FullCellId != result.CellId)
+ _ = checked(record.SpatialAuthorityVersion + 1UL);
+ if (!_physics.TryPrepareSpatialRootAdmission(record))
+ return false;
+
+ ulong sequence = checked(expectedProjectionSequence + 1UL);
+ ulong spatial = record.SpatialAuthorityVersion
+ + (record.FullCellId == result.CellId ? 0UL : 1UL);
+ ulong placement = checked(record.PlacementCommitVersion + 1UL);
+ var token = new RuntimePlacementProjectionToken(
+ sequence,
+ Revision: 1UL,
+ operation.Key,
+ operation.PositionAuthorityVersion,
+ spatial,
+ placement,
+ operation.SessionLifetimeVersion,
+ result.CellId,
+ operation.CollisionGeneration,
+ evaluation.Command.Portal);
+ projection = new RuntimePlacementProjectionSnapshot(
+ token,
+ RuntimePlacementProjectionKind.Place,
+ result.Position,
+ result.Orientation,
+ result.CellLocalPosition,
+ result.InContact,
+ result.OnWalkable);
+ pendingProjection = new SortedDictionary<
+ ulong,
+ RuntimePlacementProjectionSnapshot>(_pendingProjection)
+ {
+ [sequence] = projection,
+ };
+ }
+
+ prepared = new PreparedDormantSetPositionCommit
+ {
+ Evaluation = evaluation,
+ Entity = operation.Key,
+ OperationId = operation.Token.OperationId,
+ ExpectedProjectionSequence = expectedProjectionSequence,
+ Shadow = shadow,
+ Collision = collision,
+ Projection = projection,
+ PendingProjection = pendingProjection,
+ DeferredCollisionGeneration = deferredCollisionGeneration,
+ DeferredBucket = deferredBucket,
+ DeferredBucketIsNew = deferredBucketIsNew,
+ };
+ return IsPreparedDormantCommitCurrent(record, body, prepared);
+ }
+
+ internal bool TryApplyDormantLocalActivationCommit(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ PlayerMovementController controller,
+ EntityPhysicsHost physicsHost,
+ PreparedDormantSetPositionCommit prepared,
+ out RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentNullException.ThrowIfNull(body);
+ ArgumentNullException.ThrowIfNull(controller);
+ ArgumentNullException.ThrowIfNull(physicsHost);
+ ArgumentNullException.ThrowIfNull(prepared);
+ receipt = default;
+ if (!IsPreparedDormantCommitCurrent(record, body, prepared)
+ || !_operations.TryGetValue(
+ prepared.Entity,
+ out Operation? operation))
+ {
+ return false;
+ }
+
+ PhysicsSetPositionResult result = prepared.Evaluation.Result;
+ operation.Body = body;
+ operation.DormantLocalActivation = true;
+ if (result.IsDeferred)
+ {
+ if (prepared.Shadow is null
+ || !_physics.Engine.ShadowObjects.TryApplySetPosition(
+ prepared.Shadow,
+ out ShadowObjectRegistry.SetPositionShadowCommitReceipt
+ deferredShadowReceipt))
+ {
+ return false;
+ }
+ body.Orientation = result.Orientation;
+ body.StageDormantCellFrame(
+ result.CellId,
+ result.Position,
+ result.CellLocalPosition);
+ body.InWorld = false;
+ body.TransientState &= ~TransientStateFlags.Active;
+ operation.Result = result;
+ operation.ExactCellId = result.CellId;
+ operation.WakeableLostCell = true;
+ operation.CollisionGeneration = prepared
+ .DeferredCollisionGeneration;
+ operation.CollisionGenerationReady = false;
+ operation.Stage = RuntimeEntityPlacementStage.AwaitingCell;
+ _preparedMovers[operation.Key] = prepared.Evaluation.Command.Physics;
+ if (prepared.DeferredBucket is { } deferredBucket)
+ {
+ var bucketKey = new CellGenerationKey(
+ result.CellId,
+ prepared.DeferredCollisionGeneration);
+ if (prepared.DeferredBucketIsNew)
+ {
+ _deferredByCellGeneration.Add(bucketKey, deferredBucket);
+ _deferredBucketOrder.Add(bucketKey);
+ }
+ else if (!deferredBucket.Contains(operation.Key))
+ {
+ deferredBucket.Add(operation.Key);
+ }
+ }
+ receipt = new RuntimeDormantSetPositionCommitReceipt(
+ RuntimeDormantSetPositionCommitStatus.DeferredCell,
+ operation.Key,
+ operation.Token.OperationId,
+ default,
+ default,
+ deferredShadowReceipt,
+ prepared.Evaluation.CollisionAuthority,
+ operation.Record.VectorAuthorityVersion,
+ HitGround: false,
+ LeaveGround: false);
+ return true;
+ }
+
+ if (prepared.Collision is null
+ || !_physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared.Collision,
+ out RuntimeCollisionReportingState
+ .SetPositionCollisionBatchReceipt collisionReceipt))
+ {
+ return false;
+ }
+
+ if (!result.IsCommitted)
+ {
+ operation.Result = result;
+ receipt = new RuntimeDormantSetPositionCommitReceipt(
+ RuntimeDormantSetPositionCommitStatus.RejectedPlacement,
+ operation.Key,
+ operation.Token.OperationId,
+ default,
+ collisionReceipt,
+ default,
+ prepared.Evaluation.CollisionAuthority,
+ operation.Record.VectorAuthorityVersion,
+ HitGround: false,
+ LeaveGround: false);
+ return true;
+ }
+
+ bool previousOnWalkable = operation.PreviousOnWalkable;
+ bool hitGround = !previousOnWalkable
+ && result.InContact
+ && result.OnWalkable;
+ bool leaveGround = previousOnWalkable
+ && !(result.InContact && result.OnWalkable);
+
+ UnindexDeferred(operation);
+ body.Orientation = result.Orientation;
+ body.StageDormantCellFrame(
+ result.CellId,
+ result.Position,
+ result.CellLocalPosition);
+ body.LastUpdateTime = prepared.Evaluation.Command.GameTime;
+ body.ContactPlaneValid = result.InContact;
+ body.ContactPlane = result.ContactPlane;
+ body.ContactPlaneCellId = result.ContactPlaneCellId;
+ body.ContactPlaneIsWater = result.ContactPlaneIsWater;
+ if (result.InContact)
+ body.GroundNormal = result.ContactPlane.Normal;
+ _ = PhysicsObjUpdate.CommitSetPositionContactPrefix(
+ body,
+ result.InContact,
+ result.OnWalkable,
+ previousOnWalkable);
+ operation.Result = result;
+ operation.ExactCellId = result.CellId;
+ operation.WakeableLostCell = false;
+ operation.CollisionGenerationReady = false;
+ operation.EnteringWorldFromCelllessResidence = false;
+ operation.Stage = RuntimeEntityPlacementStage
+ .AwaitingFinalShadowPreparation;
+ receipt = new RuntimeDormantSetPositionCommitReceipt(
+ RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation,
+ operation.Key,
+ operation.Token.OperationId,
+ prepared.Projection,
+ collisionReceipt,
+ default,
+ prepared.Evaluation.CollisionAuthority,
+ operation.Record.VectorAuthorityVersion,
+ hitGround,
+ leaveGround);
+ return true;
+ }
+
+ internal SetPositionCollisionBatchDispatchResult
+ DispatchDormantLocalActivationCollision(
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (receipt.Status
+ is RuntimeDormantSetPositionCommitStatus.DeferredCell
+ or RuntimeDormantSetPositionCommitStatus.RejectedAuthority)
+ {
+ return new(
+ SetPositionCollisionBatchDispatchStatus.RejectedReceipt,
+ Reported: false);
+ }
+ SetPositionCollisionBatchDispatchResult dispatch = _physics
+ .CollisionReports.DispatchSetPositionBatchResult(receipt.Collision);
+ bool reported = dispatch.Reported;
+ if (receipt.Status
+ is RuntimeDormantSetPositionCommitStatus.RejectedPlacement
+ && _operations.TryGetValue(receipt.Entity, out Operation? operation)
+ && operation.Token.OperationId == receipt.OperationId
+ && IsCurrent(operation)
+ && !operation.Result.IsCommitted
+ && !operation.Result.IsDeferred)
+ {
+ operation.Result = operation.Result with
+ {
+ Error = reported
+ ? PhysicsSetPositionError.Collided
+ : PhysicsSetPositionError.NoValidPosition,
+ CollisionHandlerResult = reported,
+ };
+ }
+ return dispatch;
+ }
+
+ internal bool IsDormantLocalActivationPrephaseCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ return receipt.Status is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation
+ && _operations.TryGetValue(receipt.Entity, out Operation? operation)
+ && operation.Token.OperationId == receipt.OperationId
+ && operation.Stage is RuntimeEntityPlacementStage
+ .AwaitingFinalShadowPreparation
+ && operation.DormantLocalActivation
+ && IsCurrent(operation)
+ && ReferenceEquals(operation.Record, record)
+ && ReferenceEquals(record.PhysicsBody, body)
+ && !body.InWorld
+ && (body.TransientState & TransientStateFlags.Active) == 0
+ && record.PhysicsHost is null
+ && record.RemoteMotion is null
+ && record.Projectile is null
+ && !_physics.IsSpatialRoot(record)
+ && _moverPreparationAuthorities.TryGetValue(
+ receipt.Entity,
+ out MoverPreparationAuthority authority)
+ && authority.OperationId == receipt.OperationId
+ && authority.Prepared
+ && record.PositionAuthorityVersion
+ == authority.PositionAuthorityVersion
+ && record.ObjDescAuthorityVersion
+ == authority.ObjDescAuthorityVersion
+ && record.CreateIntegrationVersion
+ == authority.CreateIntegrationVersion
+ && CanonicalSetupTableId(record) == authority.SetupTableId;
+ }
+
+ internal bool IsDormantLocalActivationResponseCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (receipt.Status is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation)
+ return IsDormantLocalActivationPrephaseCurrent(record, body, receipt);
+ return receipt.Status is RuntimeDormantSetPositionCommitStatus
+ .RejectedPlacement
+ && _operations.TryGetValue(receipt.Entity, out Operation? operation)
+ && operation.Token.OperationId == receipt.OperationId
+ && operation.DormantLocalActivation
+ && IsCurrent(operation)
+ && ReferenceEquals(operation.Record, record)
+ && ReferenceEquals(record.PhysicsBody, body)
+ && !operation.Result.IsCommitted
+ && !operation.Result.IsDeferred
+ && !body.InWorld
+ && (body.TransientState & TransientStateFlags.Active) == 0
+ && record.PhysicsHost is null
+ && record.RemoteMotion is null
+ && record.Projectile is null
+ && !_physics.IsSpatialRoot(record);
+ }
+
+ internal bool CommitDormantLocalActivationPostGround(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (!IsDormantLocalActivationPrephaseCurrent(record, body, receipt))
+ return false;
+ PhysicsObjUpdate.CommitSetPositionPostGround(body);
+ PhysicsSetPositionResult result = _operations[receipt.Entity].Result;
+ body.SlidingNormal = result.SlidingNormal;
+ if (result.SlidingNormalValid)
+ body.TransientState |= TransientStateFlags.Sliding;
+ else
+ body.TransientState &= ~TransientStateFlags.Sliding;
+ return IsDormantLocalActivationPrephaseCurrent(record, body, receipt);
+ }
+
+ internal bool CommitDormantLocalActivationPostCollision(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (!_operations.TryGetValue(receipt.Entity, out Operation? operation)
+ || operation.Token.OperationId != receipt.OperationId
+ || !IsCurrent(operation)
+ || !ReferenceEquals(operation.Record, record)
+ || !ReferenceEquals(record.PhysicsBody, body))
+ {
+ return false;
+ }
+ if (receipt.Status is RuntimeDormantSetPositionCommitStatus
+ .AwaitingFinalShadowPreparation
+ && !IsDormantLocalActivationPrephaseCurrent(record, body, receipt))
+ {
+ return false;
+ }
+ PhysicsSetPositionResult result = operation.Result;
+ body.FramesStationaryFall = result.FramesStationaryFall;
+ if (IsVelocityCurrent(operation))
+ {
+ PhysicsObjUpdate.HandleAllCollisions(
+ body,
+ result.CollisionNormalValid,
+ result.CollisionNormal,
+ operation.PreviousContact,
+ operation.PreviousOnWalkable,
+ body.OnWalkable);
+ }
+ CommitStationaryBits(body, result.FramesStationaryFall);
+ return IsCurrent(operation);
+ }
+
+ internal bool TryPrepareDormantLocalActivationFinalCommit(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt,
+ bool provenShapeless,
+ out PreparedDormantActivationFinalCommit? prepared)
+ {
+ prepared = null;
+ if (!IsDormantLocalActivationPrephaseCurrent(record, body, receipt)
+ || !_operations.TryGetValue(receipt.Entity, out Operation? operation))
+ {
+ return false;
+ }
+ PhysicsSetPositionResult result = operation.Result;
+ if (!_physics.Engine.ShadowObjects.TryPrepareSetPosition(
+ operation.Key.LocalEntityId,
+ result.Position,
+ result.Orientation,
+ result.CellId,
+ operation.Command.ShadowWorldOffsetX,
+ operation.Command.ShadowWorldOffsetY,
+ result.ShadowAction,
+ result.CrossCellIds,
+ provenShapeless,
+ suspendOwner: false,
+ out ShadowObjectRegistry.PreparedSetPositionShadowCommit? shadow)
+ || shadow is null
+ || _nextProjectionSequence + 1UL
+ != receipt.Projection.Token.Sequence)
+ {
+ return false;
+ }
+ var pending = new SortedDictionary<
+ ulong,
+ RuntimePlacementProjectionSnapshot>(_pendingProjection)
+ {
+ [receipt.Projection.Token.Sequence] = receipt.Projection,
+ };
+ prepared = new PreparedDormantActivationFinalCommit
+ {
+ Entity = receipt.Entity,
+ OperationId = receipt.OperationId,
+ ExpectedProjectionSequence = _nextProjectionSequence,
+ Shadow = shadow,
+ Projection = receipt.Projection,
+ PendingProjection = pending,
+ };
+ bool current = IsDormantLocalActivationPrephaseCurrent(
+ record, body, receipt);
+ bool shadowCurrent = _physics.Engine.ShadowObjects
+ .IsPreparedSetPositionCurrent(shadow);
+ return current && shadowCurrent;
+ }
+
+ internal bool TryApplyDormantLocalActivationFinalCommit(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ PlayerMovementController controller,
+ EntityPhysicsHost physicsHost,
+ in RuntimeDormantSetPositionCommitReceipt prephase,
+ PreparedDormantActivationFinalCommit prepared,
+ out RuntimeDormantSetPositionCommitReceipt committed)
+ {
+ committed = default;
+ if (prepared.Entity != prephase.Entity
+ || prepared.OperationId != prephase.OperationId
+ || prepared.ExpectedProjectionSequence != _nextProjectionSequence
+ || prepared.Projection != prephase.Projection
+ || !IsDormantLocalActivationPrephaseCurrent(record, body, prephase)
+ || !_physics.Engine.ShadowObjects.TryApplySetPosition(
+ prepared.Shadow,
+ out ShadowObjectRegistry.SetPositionShadowCommitReceipt shadow))
+ {
+ return false;
+ }
+ Operation operation = _operations[prephase.Entity];
+ PhysicsSetPositionResult result = operation.Result;
+ if (record.FullCellId != result.CellId)
+ {
+ _entities.SetFullCell(record, result.CellId,
+ (result.CellId & 0xFFFF0000u) | 0xFFFFu);
+ }
+ operation.SpatialAuthorityVersion = record.SpatialAuthorityVersion;
+ _entities.AdvancePlacementCommit(record);
+ operation.PlacementCommitVersion = record.PlacementCommitVersion;
+ body.InWorld = true;
+ bool isStatic = (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0;
+ if (!isStatic)
+ body.TransientState |= TransientStateFlags.Active;
+ _entities.SetPhysicsHost(record, physicsHost);
+ controller.CommitRuntimeActivationFrame();
+ _physics.Engine.UpdatePlayerCurrCell(result.CellId);
+ _physics.AcknowledgeSpatialProjection(record, spatial: true);
+ _entities.ResetObjectClockForEnterWorld(record, isStatic);
+ operation.Stage = RuntimeEntityPlacementStage.AwaitingCommitAcknowledgement;
+ operation.ProjectionSequence = prepared.Projection.Token.Sequence;
+ _pendingProjection = prepared.PendingProjection;
+ _nextProjectionSequence = prepared.Projection.Token.Sequence;
+ CancelLostFamilyDeadlines(operation);
+ controller.ActivateRuntimePublication();
+ committed = prephase with
+ {
+ Status = RuntimeDormantSetPositionCommitStatus.Committed,
+ Shadow = shadow,
+ };
+ return true;
+ }
+
+ internal void DispatchDormantLocalActivationShadow(
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (receipt.Status is not (
+ RuntimeDormantSetPositionCommitStatus.Committed
+ or RuntimeDormantSetPositionCommitStatus.DeferredCell))
+ return;
+ _physics.Engine.ShadowObjects.DispatchSetPositionCommit(receipt.Shadow);
+ }
+
+ internal void DispatchDormantLocalActivationPlacement(
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (!receipt.IsCommitted)
+ return;
+ PublishPlacement(receipt.Projection);
+ }
+
+ internal void DiscardDormantLocalActivationDispatches(
+ in RuntimeDormantSetPositionCommitReceipt receipt,
+ bool collisionAlreadyDispatched)
+ {
+ if (!collisionAlreadyDispatched)
+ _physics.CollisionReports.DiscardSetPositionBatch(receipt.Collision);
+ _physics.Engine.ShadowObjects.DiscardSetPositionCommit(receipt.Shadow);
+ }
+
+ internal void RetireDormantLocalActivation(
+ in RuntimeDormantSetPositionCommitReceipt receipt,
+ bool collisionAlreadyDispatched)
+ {
+ DiscardDormantLocalActivationDispatches(
+ receipt,
+ collisionAlreadyDispatched);
+ _physics.CollisionReports.RetireSetPositionBatchOwner(
+ receipt.Collision);
+ if (_operations.TryGetValue(receipt.Entity, out Operation? operation)
+ && operation.Token.OperationId == receipt.OperationId)
+ {
+ _ = CancelCore(operation);
+ }
+ }
+
+ internal void RetireDormantLocalActivationToken(
+ RuntimeEntityRecord record,
+ in RuntimeEntityPlacementToken token)
+ {
+ if (!token.IsValid
+ || record.Key != token.Entity
+ || !_operations.TryGetValue(token.Entity, out Operation? operation)
+ || operation.Token != token
+ || !ReferenceEquals(operation.Record, record))
+ {
+ return;
+ }
+ _ = CancelCore(operation);
+ }
+
+ internal bool IsDormantLocalActivationCommitCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionCommitReceipt receipt)
+ {
+ if (!receipt.IsCommitted
+ || !receipt.Projection.Token.IsValid
+ || record.Key != receipt.Projection.Token.Entity
+ || !ReferenceEquals(record.PhysicsBody, body)
+ || !_pendingProjection.TryGetValue(
+ receipt.Projection.Token.Sequence,
+ out RuntimePlacementProjectionSnapshot pending)
+ || pending != receipt.Projection
+ || !_operations.TryGetValue(
+ receipt.Projection.Token.Entity,
+ out Operation? operation)
+ || operation.Stage is not RuntimeEntityPlacementStage
+ .AwaitingCommitAcknowledgement
+ || operation.ProjectionSequence
+ != receipt.Projection.Token.Sequence
+ || !IsCurrent(operation)
+ || !body.InWorld
+ || !_physics.IsSpatialRoot(record))
+ {
+ return false;
+ }
+ return true;
+ }
+
+ internal bool TryCaptureDormantLocalActivationResult(
+ in RuntimeEntityPlacementToken token,
+ out PhysicsSetPositionResult result)
+ {
+ if (!_disposed
+ && token.IsValid
+ && _operations.TryGetValue(token.Entity, out Operation? operation)
+ && operation.Token == token)
+ {
+ result = operation.Result;
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ private bool IsPreparedDormantCommitCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ PreparedDormantSetPositionCommit prepared)
+ {
+ if (_nextProjectionSequence != prepared.ExpectedProjectionSequence
+ || !IsDormantLocalEvaluationCurrent(
+ record,
+ body,
+ prepared.Evaluation)
+ || !_operations.TryGetValue(
+ prepared.Entity,
+ out Operation? operation)
+ || operation.Token.OperationId != prepared.OperationId)
+ {
+ return false;
+ }
+ if (prepared.Collision is not null
+ && !_physics.CollisionReports.IsPreparedSetPositionBatchCurrent(
+ prepared.Collision))
+ return false;
+ return prepared.Shadow is null
+ || _physics.Engine.ShadowObjects.IsPreparedSetPositionCurrent(
+ prepared.Shadow);
+ }
+
+ private static void CommitStationaryBits(
+ PhysicsBody body,
+ int framesStationaryFall)
+ {
+ body.TransientState &= ~(TransientStateFlags.StationaryFall
+ | TransientStateFlags.StationaryStop
+ | TransientStateFlags.StationaryStuck);
+ body.TransientState |= framesStationaryFall switch
+ {
+ 1 => TransientStateFlags.StationaryFall,
+ 2 => TransientStateFlags.StationaryStop,
+ 3 => TransientStateFlags.StationaryStuck,
+ _ => TransientStateFlags.None,
+ };
+ }
+
internal RuntimeSetPositionOutcome SubmitPreparedPlacement(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command) =>
@@ -1559,6 +2412,12 @@ internal sealed class RuntimeSetPositionState : IDisposable
private void RetryDeferred(Operation operation)
{
+ // The local-player activation lease owns its dormant body/controller
+ // and must re-enter through the same sealed evaluation/commit path.
+ // A collision-generation wake only marks readiness; it must never
+ // bypass that path through the ordinary remote CommitCanonical tail.
+ if (operation.DormantLocalActivation)
+ return;
if (!IsCurrent(operation)
|| !operation.WakeableLostCell
|| operation.RequiresPreparation
@@ -2006,7 +2865,8 @@ internal sealed class RuntimeSetPositionState : IDisposable
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command,
out Operation? operation,
- bool allowCanonicalCommand = false)
+ bool allowCanonicalCommand = false,
+ bool allowDeferredLease = false)
{
operation = null;
if (!token.IsValid
@@ -2021,6 +2881,14 @@ internal sealed class RuntimeSetPositionState : IDisposable
|| operation.Token != token
|| operation.Stage
is not RuntimeEntityPlacementStage.AwaitingPreparation
+ && !(allowDeferredLease
+ && operation.DormantLocalActivation
+ && (operation.Stage
+ is RuntimeEntityPlacementStage.AwaitingCell
+ && operation.WakeableLostCell
+ || operation.Stage is RuntimeEntityPlacementStage
+ .AwaitingFinalShadowPreparation)
+ && operation.ProjectionSequence == 0UL)
|| !ReferenceEquals(operation.Record, record)
|| !IsCurrent(operation)
|| !ReferenceEquals(record.PhysicsBody, body)
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
index 4d914cd1..2b3d8a51 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
@@ -10,6 +10,284 @@ public sealed class ShadowSetPositionCommitTests
private const uint Cell1 = Landblock | 0x0001u;
private const uint Cell9 = Landblock | 0x0009u;
+ [Fact]
+ public void PreparedAuthoredMoveKeepsOldRowsUntilAtomicApply()
+ {
+ var registry = RegisteredSingle();
+ var moved = new Vector3(36f, 12f, 50f);
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u,
+ moved,
+ Quaternion.Identity,
+ Cell9,
+ 0f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [Cell9],
+ provenShapeless: false,
+ suspendOwner: false,
+ out var prepared));
+
+ Assert.Equal(new Vector3(12f, 12f, 50f),
+ Assert.Single(registry.GetObjectsInCell(Cell1)).Position);
+ Assert.Empty(registry.GetObjectsInCell(Cell9));
+ Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
+ Assert.True(receipt.Mutated);
+ Assert.Empty(registry.GetObjectsInCell(Cell1));
+ Assert.Equal(moved,
+ Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
+ }
+
+ [Fact]
+ public void PreparedCrossPrefixDispatchesMembershipThenMutationOnce()
+ {
+ const uint otherCell = 0xA9B50001u;
+ var registry = RegisteredSingle();
+ var callbacks = new List();
+ registry.OwnerPrefixMembershipChanged += (owner, prefix) =>
+ callbacks.Add($"prefix:{owner:X8}:{prefix:X8}");
+ registry.OwnerMutated += (owner, version) =>
+ callbacks.Add($"owner:{owner:X8}:{version}");
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u,
+ new Vector3(204f, 12f, 50f),
+ Quaternion.Identity,
+ otherCell,
+ 192f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [otherCell],
+ provenShapeless: false,
+ suspendOwner: false,
+ out var prepared));
+ Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
+ Assert.Empty(callbacks);
+
+ registry.DispatchSetPositionCommit(receipt);
+ registry.DispatchSetPositionCommit(receipt);
+
+ Assert.Equal(
+ [
+ "prefix:00000001:A9B40000",
+ "prefix:00000001:A9B50000",
+ $"owner:00000001:{receipt.OwnerVersion}",
+ ], callbacks);
+ }
+
+ [Fact]
+ public void TwoOwnerReceiptsDispatchExactlyOnceInReverseOrder()
+ {
+ const uint otherCell = 0xA9B50001u;
+ var registry = RegisteredSingle();
+ registry.Register(
+ 2u, 0x01000002u, new Vector3(13f, 12f, 50f),
+ Quaternion.Identity, 1f, 0f, 0f, Landblock,
+ seedCellId: Cell1, isStatic: false);
+ var owners = new List();
+ registry.OwnerMutated += (owner, _) => owners.Add(owner);
+
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
+ otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
+ [otherCell], provenShapeless: false, suspendOwner: false,
+ out var first));
+ Assert.True(registry.TryApplySetPosition(first!, out var firstReceipt));
+ Assert.True(registry.TryPrepareSetPosition(
+ 2u, new Vector3(205f, 12f, 50f), Quaternion.Identity,
+ otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
+ [otherCell], provenShapeless: false, suspendOwner: false,
+ out var second));
+ Assert.True(registry.TryApplySetPosition(second!, out var secondReceipt));
+
+ registry.DispatchSetPositionCommit(secondReceipt);
+ registry.DispatchSetPositionCommit(firstReceipt);
+ registry.DispatchSetPositionCommit(secondReceipt);
+ registry.DispatchSetPositionCommit(firstReceipt);
+
+ Assert.Equal([2u, 1u], owners);
+ Assert.Equal(0, registry.PendingSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void PrefixObserverMutationCannotRegressFinalOwnerVersion()
+ {
+ const uint otherCell = 0xA9B50001u;
+ var registry = RegisteredSingle();
+ var versions = new List();
+ var prefixes = new List();
+ bool mutated = false;
+ registry.OwnerPrefixMembershipChanged += (owner, prefix) =>
+ {
+ prefixes.Add(prefix);
+ if (mutated)
+ return;
+ mutated = true;
+ registry.UpdatePhysicsState(
+ owner,
+ (uint)PhysicsStateFlags.Hidden);
+ };
+ registry.OwnerMutated += (_, version) => versions.Add(version);
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
+ otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
+ [otherCell], provenShapeless: false, suspendOwner: false,
+ out var prepared));
+ Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
+
+ registry.DispatchSetPositionCommit(receipt);
+
+ Assert.Single(versions);
+ Assert.Equal(registry.GetOwnerVersion(1u), versions[0]);
+ Assert.Equal([Landblock], prefixes);
+ }
+
+ [Fact]
+ public void PreparedShapelessRequiresExplicitDispositionAndCreatesNoRows()
+ {
+ var registry = new ShadowObjectRegistry();
+ Assert.False(registry.TryPrepareSetPosition(
+ 77u,
+ Vector3.One,
+ Quaternion.Identity,
+ Cell1,
+ 0f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [Cell1],
+ provenShapeless: false,
+ suspendOwner: false,
+ out _));
+ Assert.True(registry.TryPrepareSetPosition(
+ 77u,
+ Vector3.One,
+ Quaternion.Identity,
+ Cell1,
+ 0f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [Cell1],
+ provenShapeless: true,
+ suspendOwner: false,
+ out var prepared));
+ Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
+ Assert.False(receipt.Mutated);
+ Assert.Empty(registry.GetObjectsInCell(Cell1));
+ Assert.False(registry.TryApplySetPosition(prepared!, out _));
+ }
+
+ [Fact]
+ public void ClearInvalidatesPreparedUnappliedShapelessCommit()
+ {
+ var registry = new ShadowObjectRegistry();
+ Assert.True(registry.TryPrepareSetPosition(
+ 77u,
+ Vector3.One,
+ Quaternion.Identity,
+ Cell1,
+ 0f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [Cell1],
+ provenShapeless: true,
+ suspendOwner: false,
+ out var prepared));
+
+ registry.Clear();
+
+ Assert.False(registry.TryApplySetPosition(prepared!, out _));
+ Assert.Equal(0, registry.PendingSetPositionDispatchCount);
+ Assert.Empty(registry.GetObjectsInCell(Cell1));
+ }
+
+ [Fact]
+ public void PreparedCommitRejectsStaleGlobalRevisionWithoutMutation()
+ {
+ var registry = RegisteredSingle();
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
+ Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
+ provenShapeless: false, suspendOwner: false, out var prepared));
+ registry.Register(
+ 2u, 0x01000002u, new Vector3(13f, 12f, 50f),
+ Quaternion.Identity, 1f, 0f, 0f, Landblock,
+ seedCellId: Cell1, isStatic: false);
+ Vector3 before = registry.GetObjectsInCell(Cell1)
+ .Single(entry => entry.EntityId == 1u).Position;
+
+ Assert.False(registry.TryApplySetPosition(prepared!, out _));
+
+ Assert.Equal(before, registry.GetObjectsInCell(Cell1)
+ .Single(entry => entry.EntityId == 1u).Position);
+ Assert.Empty(registry.GetObjectsInCell(Cell9));
+ }
+
+ [Fact]
+ public void PreparedCommitRejectsStaleOwnerVersionWithoutMutation()
+ {
+ var registry = RegisteredSingle();
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
+ Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
+ provenShapeless: false, suspendOwner: false, out var prepared));
+ registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden);
+ ShadowEntry before = Assert.Single(registry.GetObjectsInCell(Cell1));
+
+ Assert.False(registry.TryApplySetPosition(prepared!, out _));
+
+ Assert.Equal(before, Assert.Single(registry.GetObjectsInCell(Cell1)));
+ Assert.Empty(registry.GetObjectsInCell(Cell9));
+ }
+
+ [Fact]
+ public void PreparedApplyTailAllocatesZeroManagedBytes()
+ {
+ // Warm the generic owner-value and dictionary replacement paths.
+ var warm = RegisteredSingle();
+ Assert.True(warm.TryPrepareSetPosition(
+ 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
+ Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
+ provenShapeless: false, suspendOwner: false, out var warmPrepared));
+ Assert.True(warm.TryApplySetPosition(warmPrepared!, out _));
+
+ var registry = RegisteredSingle();
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
+ Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
+ provenShapeless: false, suspendOwner: false, out var prepared));
+ long before = GC.GetAllocatedBytesForCurrentThread();
+
+ bool applied = registry.TryApplySetPosition(prepared!, out _);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.True(applied);
+ Assert.Equal(0, allocated);
+ }
+
+ [Fact]
+ public void DeferredDispatchRejectsReceiptAfterInterveningOwnerMutation()
+ {
+ const uint otherCell = 0xA9B50001u;
+ var registry = RegisteredSingle();
+ var versions = new List();
+ var prefixes = new List();
+ registry.OwnerMutated += (_, version) => versions.Add(version);
+ registry.OwnerPrefixMembershipChanged += (_, prefix) =>
+ prefixes.Add(prefix);
+ Assert.True(registry.TryPrepareSetPosition(
+ 1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
+ otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
+ [otherCell], provenShapeless: false, suspendOwner: false,
+ out var prepared));
+ Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
+
+ registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden);
+ ulong current = registry.GetOwnerVersion(1u);
+ registry.DispatchSetPositionCommit(receipt);
+
+ Assert.Equal([current], versions);
+ Assert.Empty(prefixes);
+ }
+
[Fact]
public void NoneRefreshesFrameWithoutChangingExactMembership()
{
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
index d9ba7ffe..d4c2c2bb 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
@@ -1156,6 +1156,13 @@ public class PlayerMovementControllerTests
candidate.CaptureMovementResult(mouseLookEvent: false));
Assert.Throws(() => _ = candidate.PhysicsBody);
+ candidate.BeginDormantSetPositionGroundPhase();
+ Assert.Throws(
+ candidate.CommitRuntimeActivationFrame);
+ Assert.Throws(
+ candidate.ActivateRuntimePublication);
+ candidate.EndDormantSetPositionGroundPhase();
+
candidate.ActivateRuntimePublication();
Assert.Same(body, candidate.PhysicsBody);
_ = candidate.CaptureMovementResult(mouseLookEvent: false);
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
index c9ed432f..1768124f 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
@@ -45,6 +45,30 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.True(token.IsValid);
}
+ [Fact]
+ public void ActivationIdExhaustionMutatesNoCandidateOrCanonicalOwner()
+ {
+ using var fixture = new Fixture();
+ typeof(RuntimeLocalPlayerPhysicsPublicationState)
+ .GetField(
+ "_nextActivationId",
+ System.Reflection.BindingFlags.Instance
+ | System.Reflection.BindingFlags.NonPublic)!
+ .SetValue(fixture.Owner, ulong.MaxValue);
+
+ Assert.Throws(() => fixture.Prepare());
+
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().PendingActivationCount);
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .IsExactPreparedPlacementCurrent(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command));
+ }
+
[Fact]
public void CommitOwnsExactDormantBodyAndControllerWithoutWorldEdges()
{
@@ -115,6 +139,39 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Owner.Commit(token));
}
+ [Fact]
+ public void InitialPhysicsVectorsAndCoefficientsSeedCanonicalActivation()
+ {
+ Vector3 omega = new(1f, 2f, 3f);
+ using var fixture = new Fixture(
+ residentWorld: true,
+ initialVelocity: new Vector3(100f, 0f, 0f),
+ initialOmega: omega,
+ initialFriction: 0.25f,
+ initialElasticity: 0.08f);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody);
+ Assert.Equal(new Vector3(50f, 0f, 0f), body.Velocity);
+ Assert.Equal(omega, body.Omega);
+ Assert.Equal(0.25f, body.Friction);
+ Assert.Equal(0.08f, body.Elasticity);
+ Assert.False(body.InWorld);
+ Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ Assert.Equal(new Vector3(50f, 0f, 0f), body.Velocity);
+ Assert.Equal(omega, body.Omega);
+ Assert.Equal(0.25f, body.Friction);
+ Assert.Equal(0.08f, body.Elasticity);
+ Assert.True(body.InWorld);
+ }
+
[Fact]
public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable()
{
@@ -198,6 +255,752 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
AssertNotLive(fixture.Movement.Controller!);
}
+ [Fact]
+ public void CommitActivationPublishesExactLiveRuntimeGraphOnce()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ bool callbackSawExactGraph = false;
+ var placements = new PlacementObserver(delta =>
+ {
+ if (delta.Placement.Kind is not RuntimePlacementProjectionKind.Place)
+ return;
+ callbackSawExactGraph = fixture.Record.PhysicsBody is { InWorld: true }
+ && fixture.Record.PhysicsHost is not null
+ && fixture.Movement.Controller is { IsRuntimePublished: true }
+ && fixture.Movement.Controller.Movement.MoveTo is not null
+ && fixture.Record.ObjectClock.IsActive
+ && fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record);
+ });
+ using IDisposable subscription = fixture.Lifetime.Events
+ .SubscribePlacement(placements);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+ ulong clockEpoch = fixture.Record.ObjectClockEpoch;
+
+ RuntimeDormantSetPositionCommitStatus status = fixture.Owner
+ .CommitActivation(evaluation, out var projection);
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed, status);
+ Assert.True(projection.IsValid);
+ PhysicsBody body = Assert.IsType(fixture.Record.PhysicsBody);
+ Assert.True(body.InWorld);
+ Assert.True(body.TransientState.HasFlag(TransientStateFlags.Active));
+ Assert.NotNull(fixture.Record.PhysicsHost);
+ Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.True(fixture.Record.ObjectClock.IsActive);
+ Assert.Equal(clockEpoch + 1UL, fixture.Record.ObjectClockEpoch);
+ Assert.True(fixture.Movement.Controller!.IsRuntimePublished);
+ RuntimePlacementDelta place = Assert.Single(placements.Deltas);
+ Assert.Equal(RuntimePlacementProjectionKind.Place,
+ place.Placement.Kind);
+ Assert.Equal(projection.Sequence,
+ place.Placement.Token.Sequence);
+ Assert.True(callbackSawExactGraph);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(evaluation, out _));
+ Assert.Single(placements.Deltas);
+ }
+
+ [Fact]
+ public void DeferredCommitWaitsThenRearmsSameLeaseAfterExactGenerationWake()
+ {
+ using var fixture = new Fixture();
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var deferred));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell,
+ fixture.Owner.CommitActivation(deferred, out var noProjection));
+ Assert.False(noProjection.IsValid);
+ Assert.False(fixture.Record.PhysicsBody!.InWorld);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var waiting));
+ Assert.False(waiting.IsValid);
+ Assert.Equal(1, fixture.Owner.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(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var ready));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(ready, out var projection));
+ Assert.True(projection.IsValid);
+ Assert.True(fixture.Record.PhysicsBody!.InWorld);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ }
+
+ [Fact]
+ public void RejectedCommitAppliesResponseOnceAndRetainsRetryableLease()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+ Vector3 position = body.Position;
+ uint fullCell = fixture.Record.FullCellId;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (_, phase, _, observed) => phase
+ is TransitionCellCollisionPhase.Objects
+ ? TransitionState.Collided
+ : observed;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement,
+ fixture.Owner.EvaluateActivation(token, out var rejected));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedPlacement,
+ fixture.Owner.CommitActivation(rejected, out var projection));
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .TryCaptureDormantLocalActivationResult(
+ token.Placement,
+ out PhysicsSetPositionResult rejectedResult));
+ Assert.Equal(PhysicsSetPositionError.NoValidPosition,
+ rejectedResult.Error);
+ Assert.False(rejectedResult.CollisionHandlerResult);
+ Assert.False(projection.IsValid);
+ Assert.Equal(position, body.Position);
+ Assert.Equal(fullCell, fixture.Record.FullCellId);
+ Assert.False(body.InWorld);
+ Assert.Null(fixture.Record.PhysicsHost);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(rejected, out _));
+
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var retry));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(retry, out projection));
+ Assert.True(projection.IsValid);
+ }
+
+ [Fact]
+ public void UndefinedShadowDispositionIsRejectedBeforeCandidateConstruction()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ var invalid = new RuntimeLocalPlayerPhysicsActivationPreparation(
+ 0.48f,
+ 1.835f,
+ (RuntimeLocalPlayerShadowDisposition)0x7F);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ invalid,
+ out var token));
+ Assert.False(token.IsValid);
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
+ }
+
+ [Fact]
+ public void RejectedCommitMapsEligibleEnvironmentCallbackToCollided()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, _, _, _) =>
+ {
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ return TransitionState.Collided;
+ };
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement,
+ fixture.Owner.EvaluateActivation(token, out var rejected));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedPlacement,
+ fixture.Owner.CommitActivation(rejected, out _));
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .TryCaptureDormantLocalActivationResult(
+ token.Placement,
+ out PhysicsSetPositionResult result));
+ Assert.Equal(PhysicsSetPositionError.Collided, result.Error);
+ Assert.True(result.CollisionHandlerResult);
+ Assert.Equal(1, fixture.Lifetime.Physics.CollisionReports
+ .CaptureOwnership().OwnerCount);
+
+ Assert.True(fixture.Owner.DiscardActivation(token));
+
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Lifetime.Physics.SetPosition
+ .CaptureOwnership().ActiveOperationCount);
+ RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime
+ .Physics.CollisionReports.CaptureOwnership();
+ Assert.Equal(0, ownership.OwnerCount);
+ Assert.Equal(0, ownership.TrackedObjectCount);
+ Assert.Equal(0, ownership.ReversePeerCount);
+ Assert.Equal(0, ownership.PendingSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void RegisteredAuthoredShadowActivatesAndMovesExactRows()
+ {
+ using var fixture = new Fixture(
+ residentWorld: true,
+ shadowDisposition:
+ RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload);
+ uint localId = fixture.Record.Key!.Value.LocalEntityId;
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Register(
+ localId,
+ SetupId,
+ fixture.Command.Physics.Position + Vector3.UnitX,
+ Quaternion.Identity,
+ 0.48f,
+ 0f,
+ 0f,
+ Cell & 0xFFFF0000u,
+ ShadowCollisionType.Sphere,
+ state: (uint)fixture.Record.FinalPhysicsState,
+ seedCellId: Cell,
+ isStatic: false);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ ShadowEntry row = Assert.Single(fixture.Lifetime.Physics.Engine
+ .ShadowObjects.AllEntriesForDebug(),
+ entry => entry.EntityId == localId);
+ Assert.Equal(fixture.Record.PhysicsBody!.Position, row.Position);
+ }
+
+ [Fact]
+ public void DeferredAuthoredActivationSuspendsRowsAndExactWakeRestoresThem()
+ {
+ using var fixture = new Fixture(
+ shadowDisposition:
+ RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload);
+ uint localId = fixture.Record.Key!.Value.LocalEntityId;
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Register(
+ localId, SetupId, fixture.Command.Physics.Position,
+ Quaternion.Identity, 0.48f, 0f, 0f,
+ Cell & 0xFFFF0000u, ShadowCollisionType.Sphere,
+ state: (uint)fixture.Record.FinalPhysicsState,
+ seedCellId: Cell, isStatic: false);
+ Assert.Contains(fixture.Lifetime.Physics.Engine.ShadowObjects
+ .AllEntriesForDebug(), row => row.EntityId == localId);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var deferred));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell,
+ fixture.Owner.CommitActivation(deferred, out _));
+
+ Assert.False(fixture.Record.PhysicsBody!.InWorld);
+ Assert.DoesNotContain(fixture.Lifetime.Physics.Engine.ShadowObjects
+ .AllEntriesForDebug(), row => row.EntityId == localId);
+ Assert.Equal(1, fixture.Lifetime.Physics.Engine.ShadowObjects
+ .SuspendedRegistrationCount);
+ Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects
+ .PendingSetPositionDispatchCount);
+
+ 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(), 0f, 0f);
+ fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
+ Cell & 0xFFFF0000u, generation, ready: true);
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var ready));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(ready, out _));
+ Assert.Contains(fixture.Lifetime.Physics.Engine.ShadowObjects
+ .AllEntriesForDebug(), row => row.EntityId == localId);
+ }
+
+ [Fact]
+ public void DeferredDiscardTransfersSuspendedShadowToEntityForLaterReuse()
+ {
+ using var fixture = new Fixture(
+ shadowDisposition:
+ RuntimeLocalPlayerShadowDisposition.RegisteredAuthoredPayload);
+ uint localId = fixture.Record.Key!.Value.LocalEntityId;
+ ShadowObjectRegistry shadows = fixture.Lifetime.Physics.Engine
+ .ShadowObjects;
+ shadows.Register(
+ localId, SetupId, fixture.Command.Physics.Position,
+ Quaternion.Identity, 0.48f, 0f, 0f,
+ Cell & 0xFFFF0000u, ShadowCollisionType.Sphere,
+ state: (uint)fixture.Record.FinalPhysicsState,
+ seedCellId: Cell, isStatic: false);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var deferred));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.DeferredCell,
+ fixture.Owner.CommitActivation(deferred, out _));
+
+ Assert.True(fixture.Owner.DiscardActivation(token));
+
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Lifetime.Physics.SetPosition
+ .CaptureOwnership().ActiveOperationCount);
+ Assert.Equal(1, shadows.SuspendedRegistrationCount);
+ Assert.Equal(0, shadows.PendingSetPositionDispatchCount);
+ Assert.DoesNotContain(shadows.AllEntriesForDebug(),
+ row => row.EntityId == localId);
+
+ 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(), 0f, 0f);
+ fixture.Lifetime.Physics.SetPosition.CommitCollisionGeneration(
+ Cell & 0xFFFF0000u, generation, ready: true);
+ fixture.RepreparePlacement();
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var retry));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(retry, out var ready));
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(ready, out _));
+
+ Assert.Equal(0, shadows.SuspendedRegistrationCount);
+ Assert.Contains(shadows.AllEntriesForDebug(),
+ row => row.EntityId == localId);
+ }
+
+ [Fact]
+ public void CollisionCallbackDeleteRetiresPrephaseWithoutShadowOrPlace()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ return observed;
+ };
+ var placements = new PlacementObserver();
+ using IDisposable placementSubscription = fixture.Lifetime.Events
+ .SubscribePlacement(placements);
+ bool deleted = false;
+ var collisions = new PublicationCollisionObserver(ignoredReport =>
+ {
+ if (deleted)
+ return;
+ deleted = true;
+ 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));
+ });
+ using IDisposable collisionSubscription = fixture.Lifetime.Physics
+ .CollisionReports.Subscribe(collisions);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(evaluation, out var projection));
+
+ Assert.True(deleted);
+ Assert.False(projection.IsValid);
+ Assert.DoesNotContain(placements.Deltas,
+ delta => delta.Placement.Kind is RuntimePlacementProjectionKind.Place);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership().PendingActivationCount);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Lifetime.Physics.CollisionReports
+ .CaptureOwnership().PendingSetPositionDispatchCount);
+ Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects
+ .PendingSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void CollisionCallbackVectorRefreshesDormantBodyWithRetailClamp()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ return observed;
+ };
+ bool updated = false;
+ Vector3 omega = new(1f, 2f, 3f);
+ var collisions = new PublicationCollisionObserver(ignoredReport =>
+ {
+ if (updated)
+ return;
+ updated = true;
+ Assert.True(fixture.Lifetime.TryApplyVector(
+ new VectorUpdate.Parsed(
+ fixture.Record.ServerGuid,
+ new Vector3(100f, 0f, 0f),
+ omega,
+ InstanceSequence: fixture.Record.Incarnation,
+ VectorSequence: 2),
+ acknowledgeProjection: null,
+ out _));
+ _ = ignoredReport;
+ });
+ using IDisposable subscription = fixture.Lifetime.Physics
+ .CollisionReports.Subscribe(collisions);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ Assert.True(updated);
+ Assert.Equal(new Vector3(50f, 0f, 0f),
+ fixture.Record.PhysicsBody!.Velocity);
+ Assert.Equal(omega, fixture.Record.PhysicsBody.Omega);
+ }
+
+ [Fact]
+ public void CollisionCallbackAuthorityChangeRetiresInstalledTracking()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ return observed;
+ };
+ bool invalidated = false;
+ var collisions = new PublicationCollisionObserver(_ =>
+ {
+ if (invalidated)
+ return;
+ invalidated = true;
+ fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record);
+ });
+ using IDisposable subscription = fixture.Lifetime.Physics
+ .CollisionReports.Subscribe(collisions);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime
+ .Physics.CollisionReports.CaptureOwnership();
+ Assert.True(invalidated);
+ Assert.Equal(0, ownership.OwnerCount);
+ Assert.Equal(0, ownership.TrackedObjectCount);
+ Assert.Equal(0, ownership.ReversePeerCount);
+ Assert.Equal(0, ownership.PendingSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void CollisionCallbackNewerPositionSuppressesReciprocalAndPreservesNewLease()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ RuntimeEntityRecord target = fixture.Lifetime.RegisterEntity(
+ Spawn(0x70003002u, incarnation: 1)).Canonical!;
+ fixture.Lifetime.Entities.SetFullCell(
+ target, Cell, Cell & 0xFFFF0000u);
+ fixture.Lifetime.Entities.SetFinalPhysicsState(
+ target, PhysicsStateFlags.ReportCollisions);
+ var targetBody = new PhysicsBody
+ {
+ Position = new Vector3(1f, 2f, 3f),
+ Orientation = Quaternion.Identity,
+ State = PhysicsStateFlags.ReportCollisions,
+ InWorld = true,
+ };
+ targetBody.SnapToCell(Cell, targetBody.Position, targetBody.Position);
+ fixture.Lifetime.Entities.SetPhysicsBody(target, targetBody);
+ fixture.Lifetime.Physics.AcknowledgeSpatialProjection(
+ target, spatial: true);
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Register(
+ target.Key!.Value.LocalEntityId,
+ SetupId,
+ targetBody.Position,
+ Quaternion.Identity,
+ 0.48f,
+ 0f,
+ 0f,
+ Cell & 0xFFFF0000u,
+ ShadowCollisionType.Sphere,
+ (uint)targetBody.State,
+ Cell,
+ isStatic: false);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ {
+ transition.CollisionInfo.CollideObjectGuids.Add(
+ target.Key.Value.LocalEntityId);
+ }
+ return observed;
+ };
+ bool updated = false;
+ RuntimeEntityPlacementToken newer = default;
+ var reports = new List();
+ var collisions = new PublicationCollisionObserver(report =>
+ {
+ reports.Add(report);
+ if (updated || report.Kind is not RuntimeCollisionReportKind.ObjectCollision)
+ return;
+ updated = true;
+ Assert.True(fixture.Lifetime.TryApplyPosition(
+ new WorldSession.EntityPositionUpdate(
+ fixture.Record.ServerGuid,
+ new CreateObject.ServerPosition(
+ Cell, 4f, 5f, 3f, 1f, 0f, 0f, 0f),
+ Velocity: null,
+ PlacementId: null,
+ IsGrounded: true,
+ InstanceSequence: fixture.Record.Incarnation,
+ PositionSequence: 2,
+ TeleportSequence: 0,
+ ForcePositionSequence: 0),
+ isLocalPlayer: true,
+ forcePositionRotation: null,
+ currentLocalVelocity: null,
+ projectionRequiresTeleportHook: false,
+ acknowledgeProjection: null,
+ out _,
+ out _,
+ out _));
+ newer = fixture.Lifetime.Physics.SetPosition.BeginAuthoredPlacement(
+ fixture.Record,
+ fixture.Record.PositionAuthorityVersion,
+ RuntimeSetPositionOperationKind.LocalAuthoritative);
+ Assert.True(newer.IsValid);
+ });
+ using IDisposable subscription = fixture.Lifetime.Physics
+ .CollisionReports.Subscribe(collisions);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ Assert.True(updated);
+ Assert.DoesNotContain(reports, report =>
+ report.Kind is RuntimeCollisionReportKind.ObjectCollision
+ && report.Recipient == target.Key);
+ Assert.Equal(1, fixture.Lifetime.Physics.SetPosition
+ .CaptureOwnership().ActiveOperationCount);
+ RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime
+ .Physics.CollisionReports.CaptureOwnership();
+ Assert.Equal(0, ownership.OwnerCount);
+ Assert.Equal(0, ownership.TrackedObjectCount);
+ Assert.Equal(0, ownership.ReversePeerCount);
+ }
+
+ [Fact]
+ public void HitGroundReapplyStaysDormantUntilFinalActivationTail()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ {
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, -3f), Cell, isWater: false);
+ }
+ return observed;
+ };
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ PlayerMovementController controller = fixture.Movement.Controller!;
+ bool observedDormant = false;
+ controller.BeginDormantSetPositionGroundPhase();
+ controller.Motion.InterpretedState.ForwardCommand =
+ MotionCommand.RunForward;
+ controller.Motion.InterpretedState.ForwardSpeed = 1f;
+ controller.Motion.DefaultSink = new CallbackMotionSink(() =>
+ {
+ observedDormant = true;
+ Assert.False(fixture.Record.PhysicsBody!.InWorld);
+ Assert.False(fixture.Record.PhysicsBody.TransientState
+ .HasFlag(TransientStateFlags.Active));
+ Assert.Null(fixture.Record.PhysicsHost);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.False(fixture.Record.ObjectClock.IsActive);
+ });
+ controller.EndDormantSetPositionGroundPhase();
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(evaluation, out var projection));
+
+ Assert.True(observedDormant);
+ Assert.True(projection.IsValid);
+ Assert.True(fixture.Record.PhysicsBody!.InWorld);
+ Assert.NotNull(fixture.Record.PhysicsHost);
+ Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.True(fixture.Record.ObjectClock.IsActive);
+ Assert.True(controller.IsRuntimePublished);
+ }
+
+ [Fact]
+ public void HitGroundInvalidationRetiresInstalledBatchBeforeDispatch()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ RuntimeEntityRecord target = fixture.Lifetime.RegisterEntity(
+ Spawn(0x70003003u, incarnation: 1)).Canonical!;
+ fixture.Lifetime.Entities.SetFullCell(
+ target, Cell, Cell & 0xFFFF0000u);
+ fixture.Lifetime.Entities.SetFinalPhysicsState(
+ target, PhysicsStateFlags.ReportCollisions);
+ var targetBody = new PhysicsBody
+ {
+ Position = new Vector3(100f, 100f, 3f),
+ Orientation = Quaternion.Identity,
+ State = PhysicsStateFlags.ReportCollisions,
+ InWorld = true,
+ TransientState = TransientStateFlags.Active,
+ };
+ targetBody.SnapToCell(Cell, targetBody.Position, targetBody.Position);
+ fixture.Lifetime.Entities.SetPhysicsBody(target, targetBody);
+ fixture.Lifetime.Physics.AcknowledgeSpatialProjection(target, true);
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Register(
+ target.Key!.Value.LocalEntityId, SetupId, targetBody.Position,
+ Quaternion.Identity, 0.48f, 0f, 0f, Cell & 0xFFFF0000u,
+ ShadowCollisionType.Sphere, (uint)targetBody.State, Cell,
+ isStatic: false);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out var token));
+ PhysicsBody ownerBody = fixture.Record.PhysicsBody!;
+ ownerBody.InWorld = true;
+ ownerBody.TransientState |= TransientStateFlags.Active;
+ Assert.True(fixture.Lifetime.Physics.CollisionReports.HandleReports(
+ fixture.Record, ownerBody, 9d, false, false, false,
+ [target.Key.Value.LocalEntityId]));
+ ownerBody.InWorld = false;
+ ownerBody.TransientState &= ~TransientStateFlags.Active;
+ Assert.Equal(1, fixture.Lifetime.Physics.CollisionReports
+ .CaptureOwnership().TrackedObjectCount);
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (transition, phase, _, observed) =>
+ {
+ if (phase is TransitionCellCollisionPhase.Environment)
+ {
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, -3f), Cell, isWater: false);
+ }
+ return observed;
+ };
+ PlayerMovementController controller = fixture.Movement.Controller!;
+ bool invalidated = false;
+ controller.BeginDormantSetPositionGroundPhase();
+ controller.Motion.InterpretedState.ForwardCommand =
+ MotionCommand.RunForward;
+ controller.Motion.DefaultSink = new CallbackMotionSink(() =>
+ {
+ if (invalidated)
+ return;
+ invalidated = true;
+ fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record);
+ });
+ controller.EndDormantSetPositionGroundPhase();
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.RejectedAuthority,
+ fixture.Owner.CommitActivation(evaluation, out _));
+
+ Assert.True(invalidated);
+ RuntimeCollisionReportingOwnershipSnapshot ownership = fixture.Lifetime
+ .Physics.CollisionReports.CaptureOwnership();
+ Assert.Equal(0, ownership.OwnerCount);
+ Assert.Equal(0, ownership.TrackedObjectCount);
+ Assert.Equal(0, ownership.ReversePeerCount);
+ Assert.Equal(0, ownership.PendingSetPositionDispatchCount);
+ Assert.Equal(0, fixture.Lifetime.Physics.Engine.ShadowObjects
+ .PendingSetPositionDispatchCount);
+ Assert.Equal(0, fixture.Lifetime.Physics.SetPosition
+ .CaptureOwnership().ActiveOperationCount);
+ }
+
+ [Fact]
+ public void HiddenNoDrawStateDoesNotCancelDormantActivation()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ PhysicsStateFlags hidden = fixture.Record.FinalPhysicsState
+ | PhysicsStateFlags.Hidden
+ | PhysicsStateFlags.NoDraw;
+ fixture.Lifetime.Entities.SetFinalPhysicsState(fixture.Record, hidden);
+ fixture.RepreparePlacement();
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var evaluation));
+
+ Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
+ fixture.Owner.CommitActivation(evaluation, out var projection));
+
+ Assert.True(projection.IsValid);
+ Assert.True(fixture.Record.PhysicsBody!.InWorld);
+ Assert.True(fixture.Record.PhysicsBody.State.HasFlag(
+ PhysicsStateFlags.Hidden));
+ Assert.True(fixture.Record.PhysicsBody.State.HasFlag(
+ PhysicsStateFlags.NoDraw));
+ Assert.True(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ }
+
[Fact]
public void RejectedPlacementReceiptIsPureAndRetryableUnderSameLease()
{
@@ -813,6 +1616,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Placement,
fixture.Command,
PlayerMovementConstructionOptions.Fallback,
+ fixture.ActivationPreparation,
out _));
Assert.Equal(1, fixture.Owner.CaptureOwnership()
.PendingActivationCount);
@@ -820,12 +1624,14 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Assert.True(fixture.Owner.DiscardActivation(token));
Assert.Equal(0, fixture.Owner.CaptureOwnership()
.PendingActivationCount);
+ fixture.RepreparePlacement();
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared,
fixture.Owner.Prepare(
fixture.Record,
fixture.Placement,
fixture.Command,
PlayerMovementConstructionOptions.Fallback,
+ fixture.ActivationPreparation,
out _));
}
@@ -1077,6 +1883,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Placement,
fixture.Command,
PlayerMovementConstructionOptions.Fallback,
+ fixture.ActivationPreparation,
out RuntimeLocalPlayerPhysicsPublicationToken token));
Assert.False(token.IsValid);
Assert.Equal(0, fixture.Owner.CaptureOwnership().CandidateCount);
@@ -1095,6 +1902,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Placement,
fixture.Command,
PlayerMovementConstructionOptions.Fallback,
+ fixture.ActivationPreparation,
out _));
}
@@ -1111,6 +1919,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Placement,
fixture.Command,
PlayerMovementConstructionOptions.Fallback,
+ fixture.ActivationPreparation,
out _));
}
@@ -1360,7 +2169,14 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
internal Fixture(
bool preparePlacement = true,
- bool residentWorld = false)
+ bool residentWorld = false,
+ RuntimeLocalPlayerShadowDisposition shadowDisposition =
+ RuntimeLocalPlayerShadowDisposition.ProvenShapeless,
+ float terrainHeight = 0f,
+ Vector3? initialVelocity = null,
+ Vector3? initialOmega = null,
+ float? initialFriction = null,
+ float? initialElasticity = null)
{
if (residentWorld)
{
@@ -1370,7 +2186,9 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
};
engine.AddLandblock(
Cell & 0xFFFF0000u,
- new TerrainSurface(new byte[81], new float[256]),
+ new TerrainSurface(
+ new byte[81],
+ Enumerable.Repeat(terrainHeight, 256).ToArray()),
Array.Empty(),
Array.Empty(),
worldOffsetX: 0f,
@@ -1390,8 +2208,18 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Identity);
Movement.AttachPhysicsPublication(Owner);
Record = Lifetime.RegisterEntity(
- Spawn(0x70003001u, incarnation: 1)).Canonical!;
+ Spawn(
+ 0x70003001u,
+ incarnation: 1,
+ initialVelocity,
+ initialOmega,
+ initialFriction,
+ initialElasticity)).Canonical!;
Identity.ServerGuid = Record.ServerGuid;
+ ActivationPreparation = new(
+ Radius: 0.48f,
+ Height: 1.835f,
+ shadowDisposition);
if (preparePlacement)
RepreparePlacement();
}
@@ -1403,6 +2231,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
internal RuntimeEntityRecord Record { get; }
internal RuntimeEntityPlacementToken Placement { get; private set; }
internal RuntimeSetPositionCommand Command { get; private set; }
+ internal RuntimeLocalPlayerPhysicsActivationPreparation
+ ActivationPreparation { get; }
internal void RepreparePlacement()
{
@@ -1440,6 +2270,7 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Placement,
Command,
PlayerMovementConstructionOptions.Fallback,
+ ActivationPreparation,
out RuntimeLocalPlayerPhysicsPublicationToken token));
Assert.Equal(Record.Key, token.Entity);
Assert.Equal(Placement, token.Placement);
@@ -1464,7 +2295,50 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
}
}
- private static WorldSession.EntitySpawn Spawn(uint guid, ushort incarnation)
+ private sealed class PlacementObserver(
+ Action? onPlacement = null)
+ : IRuntimePlacementObserver
+ {
+ internal List Deltas { get; } = [];
+
+ public void OnPlacement(in RuntimePlacementDelta delta)
+ {
+ Deltas.Add(delta);
+ onPlacement?.Invoke(delta);
+ }
+ }
+
+ private sealed class PublicationCollisionObserver(
+ Action onReport)
+ : IRuntimeCollisionReportObserver
+ {
+ public void OnCollisionReport(in RuntimeCollisionReport report) =>
+ onReport(report);
+ }
+
+ private sealed class CallbackMotionSink(Action onApply)
+ : IInterpretedMotionSink
+ {
+ public bool ApplyMotion(uint motion, float speed)
+ {
+ onApply();
+ return true;
+ }
+
+ public bool StopMotion(uint motion)
+ {
+ onApply();
+ return true;
+ }
+ }
+
+ private static WorldSession.EntitySpawn Spawn(
+ uint guid,
+ ushort incarnation,
+ Vector3? initialVelocity = null,
+ Vector3? initialOmega = null,
+ float? initialFriction = null,
+ float? initialElasticity = null)
{
var position = new CreateObject.ServerPosition(
Cell,
@@ -1498,12 +2372,12 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Parent: null,
Children: null,
Scale: 1f,
- Friction: null,
- Elasticity: null,
+ Friction: initialFriction,
+ Elasticity: initialElasticity,
Translucency: null,
- Velocity: null,
+ Velocity: initialVelocity,
Acceleration: null,
- AngularVelocity: null,
+ AngularVelocity: initialOmega,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
@@ -1522,6 +2396,8 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
MotionTableId: 0x09000001u,
PhysicsState: physics.RawState,
ObjectDescriptionFlags: 0x8u,
+ Friction: initialFriction,
+ Elasticity: initialElasticity,
InstanceSequence: incarnation,
MovementSequence: 1,
ServerControlSequence: 1,
diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs
index 90de9944..efabd2e4 100644
--- a/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs
@@ -13,6 +13,604 @@ public sealed class RuntimeCollisionReportingStateTests
private const uint Landblock = 0xA9B40000u;
private const uint Cell = Landblock | 0x0001u;
+ [Fact]
+ public void PreparedBatchDefersTrackingUntilOrderedDispatchAndDispatchesOnce()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F01u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F02u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ target.PhysicsBody!.TransientState |= TransientStateFlags.Contact;
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ physicsTime: 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [target.Key!.Value.LocalEntityId],
+ out var prepared));
+ Assert.NotNull(prepared);
+ Assert.Empty(observer.Reports);
+ Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+ Assert.Empty(observer.Reports);
+ Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+ Assert.Equal(1, lifetime.Physics.CaptureOwnership()
+ .PendingCollisionSetPositionDispatchCount);
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+ Assert.Equal(2, observer.Reports.Count);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+ Assert.False(observer.Reports[0].RecipientWasInContact);
+ Assert.True(observer.Reports[0].OtherWasInContact);
+ Assert.True(observer.Reports[1].RecipientWasInContact);
+ Assert.False(observer.Reports[1].OtherWasInContact);
+ Assert.Equal(0, lifetime.Physics.CaptureOwnership()
+ .PendingCollisionSetPositionDispatchCount);
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+ Assert.Equal(2, observer.Reports.Count);
+ }
+
+ [Fact]
+ public void CombinedPhysicsLedgerIncludesPendingShadowSetPositionReceipt()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime, 0x70001F03u, 1, PhysicsStateFlags.None);
+ RegisterDynamicShadow(lifetime, owner);
+ ShadowObjectRegistry shadows = lifetime.Physics.Engine.ShadowObjects;
+ Assert.True(shadows.TryPrepareSetPosition(
+ owner.Key!.Value.LocalEntityId,
+ new Vector3(13f, 12f, 7f),
+ Quaternion.Identity,
+ Cell,
+ 0f,
+ 0f,
+ PhysicsShadowCommitAction.Replace,
+ [Cell],
+ provenShapeless: false,
+ suspendOwner: false,
+ out var prepared));
+ Assert.True(shadows.TryApplySetPosition(prepared!, out var receipt));
+
+ Assert.Equal(1, lifetime.Physics.CaptureOwnership()
+ .PendingShadowSetPositionDispatchCount);
+
+ Assert.True(shadows.DiscardSetPositionCommit(receipt));
+ Assert.Equal(0, lifetime.Physics.CaptureOwnership()
+ .PendingShadowSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void TwoOwnerPreparedBatchesDispatchExactlyOnceInReverseOrder()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord first = Entity(
+ lifetime, 0x70001E01u, 1, PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord second = Entity(
+ lifetime, 0x70001E02u, 1, PhysicsStateFlags.ReportCollisions);
+ const uint staticId = 0x00F01E01u;
+ RegisterShadow(lifetime, staticId, PhysicsStateFlags.Static,
+ isStatic: true);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ first, first.PhysicsBody!, 10d, false, false, false, false,
+ [staticId], out var firstPrepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ firstPrepared!, out var firstReceipt));
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ second, second.PhysicsBody!, 10d, false, false, false, false,
+ [staticId], out var secondPrepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ secondPrepared!, out var secondReceipt));
+
+ Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ secondReceipt));
+ Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ firstReceipt));
+ Assert.False(lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ secondReceipt));
+ Assert.False(lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ firstReceipt));
+
+ Assert.Equal(2, observer.Reports.Count);
+ Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .PendingSetPositionDispatchCount);
+ }
+
+ [Fact]
+ public void OldBatchRetirementCannotRemoveBatchInstalledByItsCallback()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime, 0x70001E03u, 1, PhysicsStateFlags.ReportCollisions);
+ const uint staticId = 0x00F01E03u;
+ RegisterShadow(lifetime, staticId, PhysicsStateFlags.Static,
+ isStatic: true);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner, owner.PhysicsBody!, 10d, false, false, false, false,
+ [staticId], out var oldPrepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ oldPrepared!, out var oldReceipt));
+ RuntimeCollisionReportingState.SetPositionCollisionBatchReceipt
+ newerReceipt = default;
+ bool installedNewer = false;
+ var observer = new CollisionObserver(ignoredReport =>
+ {
+ if (installedNewer)
+ return;
+ installedNewer = true;
+ Assert.True(lifetime.Physics.CollisionReports
+ .TryPrepareSetPositionBatch(
+ owner, owner.PhysicsBody!, 11d, false, false, false, true,
+ ImmutableArray.Empty, out var newerPrepared));
+ Assert.True(lifetime.Physics.CollisionReports
+ .TryInstallSetPositionBatch(newerPrepared!, out newerReceipt));
+ _ = lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ newerReceipt);
+ _ = ignoredReport;
+ });
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+
+ Assert.True(lifetime.Physics.CollisionReports.DispatchSetPositionBatch(
+ oldReceipt));
+ Assert.True(installedNewer);
+ Assert.True(newerReceipt.IsValid);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .OwnerCount);
+
+ lifetime.Physics.CollisionReports.RetireSetPositionBatchOwner(
+ oldReceipt);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .OwnerCount);
+
+ lifetime.Physics.CollisionReports.RetireSetPositionBatchOwner(
+ newerReceipt);
+ Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .OwnerCount);
+ }
+
+ [Fact]
+ public void PreparedBatchTracksHiddenIgnoredPeerButSuppressesCallbacks()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F11u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F12u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [target.Key!.Value.LocalEntityId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+
+ PhysicsStateFlags hidden = PhysicsStateFlags.Hidden
+ | PhysicsStateFlags.IgnoreCollisions;
+ lifetime.Entities.SetFinalPhysicsState(target, hidden);
+ target.PhysicsBody!.State = hidden;
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Empty(observer.Reports);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+ }
+
+ [Fact]
+ public void PreparedBatchConvertsLiveReportAsEnvironmentAfterGroundEdge()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F21u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F22u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [target.Key!.Value.LocalEntityId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+
+ lifetime.Entities.SetFinalPhysicsState(
+ target,
+ PhysicsStateFlags.ReportAsEnvironment);
+ target.PhysicsBody!.State = PhysicsStateFlags.ReportAsEnvironment;
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Single(observer.Reports);
+ Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision,
+ observer.Reports[0].Kind);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+ }
+
+ [Fact]
+ public void PreparedEnvironmentBatchClearsCapturedMissileWithoutReport()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F31u,
+ 1,
+ PhysicsStateFlags.Missile | PhysicsStateFlags.AlignPath);
+ RegisterDynamicShadow(lifetime, owner);
+ const uint staticId = 0x00F01F31u;
+ RegisterShadow(
+ lifetime,
+ staticId,
+ PhysicsStateFlags.Static,
+ isStatic: true);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [staticId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Empty(observer.Reports);
+ Assert.False(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile));
+ Assert.False(owner.PhysicsBody!.State.HasFlag(PhysicsStateFlags.Missile));
+ }
+
+ [Fact]
+ public void PreparedExpiredEndDegradesToMissingTargetAfterInstall()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F41u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F42u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ target.PhysicsBody!.TransientState |= TransientStateFlags.Contact;
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ false,
+ false,
+ false,
+ false,
+ [target.Key!.Value.LocalEntityId],
+ out var start));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ start!,
+ out var startReceipt));
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(startReceipt);
+ observer.Reports.Clear();
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 11.0001d,
+ false,
+ false,
+ false,
+ false,
+ ImmutableArray.Empty,
+ out var end));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ end!,
+ out var endReceipt));
+ Assert.True(lifetime.TryAcceptDelete(
+ new DeleteObject.Parsed(target.ServerGuid, target.Incarnation),
+ isLocalPlayer: false,
+ removeRetainedObject: false,
+ out RuntimeEntityDeleteAcceptance acceptance));
+ lifetime.CompleteAcceptedDelete(acceptance);
+ Assert.Null(lifetime.RetireCanonicalOnly(target));
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(endReceipt);
+
+ RuntimeCollisionReport report = Assert.Single(observer.Reports);
+ Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd, report.Kind);
+ Assert.Equal(owner.Key, report.Recipient);
+ Assert.Equal(target.ServerGuid, report.OtherServerGuid);
+ Assert.False(report.OtherWasInContact);
+ }
+
+ [Fact]
+ public void PreparedBatchPreservesPreLoopEnvironmentLatchUntilFinalSuffix()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F51u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F52u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+
+ Assert.True(lifetime.Physics.CollisionReports.HandleReports(
+ owner,
+ owner.PhysicsBody!,
+ 9d,
+ previousContact: false,
+ previousOnWalkable: false,
+ collidedWithEnvironment: true,
+ ImmutableArray.Empty));
+ observer.Reports.Clear();
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [target.Key!.Value.LocalEntityId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+ lifetime.Entities.SetFinalPhysicsState(
+ target,
+ PhysicsStateFlags.ReportAsEnvironment);
+ target.PhysicsBody!.State = PhysicsStateFlags.ReportAsEnvironment;
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Empty(observer.Reports);
+ Assert.Equal(1, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 11d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: true,
+ ImmutableArray.Empty,
+ out var next));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ next!,
+ out var nextReceipt));
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(nextReceipt);
+
+ RuntimeCollisionReport report = Assert.Single(observer.Reports);
+ Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision,
+ report.Kind);
+ }
+
+ [Fact]
+ public void PreparedEnvironmentBatchStopsMissileAddedByCallback()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F61u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, owner);
+ const uint staticId = 0x00F01F61u;
+ RegisterShadow(
+ lifetime,
+ staticId,
+ PhysicsStateFlags.Static,
+ isStatic: true);
+ var observer = new CollisionObserver(_ =>
+ {
+ PhysicsStateFlags callbackState = owner.PhysicsBody!.State
+ | PhysicsStateFlags.Missile
+ | PhysicsStateFlags.AlignPath;
+ lifetime.Entities.SetFinalPhysicsState(owner, callbackState);
+ owner.PhysicsBody.State = callbackState;
+ });
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 10d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [staticId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Single(observer.Reports);
+ Assert.False(owner.FinalPhysicsState.HasFlag(PhysicsStateFlags.Missile));
+ Assert.False(owner.PhysicsBody!.State.HasFlag(PhysicsStateFlags.Missile));
+ }
+
+ [Fact]
+ public void PreparedExistingCollisionRefreshesLiveEtherealBeforeExpiry()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F71u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F72u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+
+ DispatchPrepared(
+ lifetime,
+ owner,
+ physicsTime: 10d,
+ [target.Key!.Value.LocalEntityId]);
+ observer.Reports.Clear();
+ PhysicsStateFlags ethereal = PhysicsStateFlags.ReportCollisions
+ | PhysicsStateFlags.Ethereal;
+ lifetime.Entities.SetFinalPhysicsState(target, ethereal);
+ target.PhysicsBody!.State = ethereal;
+
+ DispatchPrepared(
+ lifetime,
+ owner,
+ physicsTime: 10.5d,
+ [target.Key!.Value.LocalEntityId]);
+ Assert.Empty(observer.Reports);
+ DispatchPrepared(
+ lifetime,
+ owner,
+ physicsTime: 10.5001d,
+ ImmutableArray.Empty);
+
+ Assert.Equal(2, observer.Reports.Count);
+ Assert.All(observer.Reports, report => Assert.Equal(
+ RuntimeCollisionReportKind.ObjectCollisionEnd,
+ report.Kind));
+ }
+
+ [Fact]
+ public void PreparedExistingCollisionBecomingStaticKeepsOldAgeForSamePassEnd()
+ {
+ using var lifetime = Lifetime();
+ RuntimeEntityRecord owner = Entity(
+ lifetime,
+ 0x70001F81u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RuntimeEntityRecord target = Entity(
+ lifetime,
+ 0x70001F82u,
+ 1,
+ PhysicsStateFlags.ReportCollisions);
+ RegisterDynamicShadow(lifetime, target);
+ var observer = new CollisionObserver();
+ using IDisposable subscription = lifetime.Physics.CollisionReports
+ .Subscribe(observer);
+ DispatchPrepared(
+ lifetime,
+ owner,
+ physicsTime: 10d,
+ [target.Key!.Value.LocalEntityId]);
+ observer.Reports.Clear();
+
+ Assert.True(lifetime.Physics.CollisionReports.TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ 11.0001d,
+ previousContact: false,
+ previousOnWalkable: false,
+ finalOnWalkable: false,
+ collidedWithEnvironment: false,
+ [target.Key!.Value.LocalEntityId],
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports.TryInstallSetPositionBatch(
+ prepared!,
+ out var receipt));
+ PhysicsStateFlags becameStatic = PhysicsStateFlags.ReportCollisions
+ | PhysicsStateFlags.Static;
+ lifetime.Entities.SetFinalPhysicsState(target, becameStatic);
+ target.PhysicsBody!.State = becameStatic;
+
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+
+ Assert.Equal(3, observer.Reports.Count);
+ Assert.Equal(RuntimeCollisionReportKind.EnvironmentCollision,
+ observer.Reports[0].Kind);
+ Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd,
+ observer.Reports[1].Kind);
+ Assert.Equal(RuntimeCollisionReportKind.ObjectCollisionEnd,
+ observer.Reports[2].Kind);
+ Assert.Equal(0, lifetime.Physics.CollisionReports.CaptureOwnership()
+ .TrackedObjectCount);
+ }
+
[Fact]
public void ExactObjectReportsAreOrderedDeduplicatedAndExpireAtRetailThreshold()
{
@@ -1656,6 +2254,28 @@ public sealed class RuntimeCollisionReportingStateTests
owner.PhysicsBody.OnWalkable,
collision);
+ private static void DispatchPrepared(
+ RuntimeEntityObjectLifetime lifetime,
+ RuntimeEntityRecord owner,
+ double physicsTime,
+ ImmutableArray collidedObjectIds)
+ {
+ Assert.True(lifetime.Physics.CollisionReports
+ .TryPrepareSetPositionBatch(
+ owner,
+ owner.PhysicsBody!,
+ physicsTime,
+ previousContact: owner.PhysicsBody!.InContact,
+ previousOnWalkable: owner.PhysicsBody.OnWalkable,
+ finalOnWalkable: owner.PhysicsBody.OnWalkable,
+ collidedWithEnvironment: false,
+ collidedObjectIds,
+ out var prepared));
+ Assert.True(lifetime.Physics.CollisionReports
+ .TryInstallSetPositionBatch(prepared!, out var receipt));
+ lifetime.Physics.CollisionReports.DispatchSetPositionBatch(receipt);
+ }
+
private static RuntimeSetPositionCommand PlacementCommand(
RuntimeEntityRecord owner,
Vector3 position)