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)