diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 0fdb1dcb..3e19d941 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -201,6 +201,9 @@ public sealed class ClientObjectTable public int ObjectCount => _objects.Count; public int ContainerCount => _containers.Count; + public int ContainerProjectionCount => _containerIndex.Count; + public int EquipmentOwnerCount => _equipmentIndex.Count; + public int PendingMoveCount => _pendingMoves.Count; public IEnumerable Objects => _objects.Values; public IEnumerable Containers => _containers.Values; diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index abdc757a..2d64f63d 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -17,6 +17,12 @@ public sealed class ParentAttachmentState private readonly Dictionary _recoveryByChild = new(); private readonly Dictionary _lastAcceptedByChild = new(); + public int UnresolvedRelationCount => + _unresolvedByChild.Values.Sum(queue => queue.Count); + public int StagedRelationCount => _stagedByChild.Count; + public int RecoveryRelationCount => _recoveryByChild.Count; + public int CommittedRelationCount => _lastAcceptedByChild.Count; + public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { _stagedByChild[relation.ChildGuid] = relation; diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 726d5c67..d721be04 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -247,6 +247,18 @@ public sealed class RuntimeEntityDirectory record.AdvanceMovementAuthority(); } + public void AdvanceMovementCommit(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceMovementCommit(); + } + + public void AdvanceParentCommit(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceParentCommit(); + } + public void AdvanceObjDescAuthority(RuntimeEntityRecord record) { EnsureKnown(record); @@ -293,8 +305,7 @@ public sealed class RuntimeEntityDirectory uint canonicalLandblockId) { EnsureKnown(record); - record.FullCellId = fullCellId; - record.CanonicalLandblockId = canonicalLandblockId; + record.SetFullCell(fullCellId, canonicalLandblockId); } public void SetFinalPhysicsState( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs index a2c4ba2f..5cc7e5c3 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs @@ -16,8 +16,10 @@ public interface IRuntimeEntityObjectEventSource /// /// One synchronous, generation-stamped commit stream for the canonical entity -/// directory and retained object table. It owns no queue and dispatches on the -/// caller's update/network-drain thread. +/// directory and retained object table. It owns no cross-frame queue and +/// dispatches on the caller's update/network-drain thread. A retained +/// synchronous drain list preserves sequence order when an observer commits +/// another mutation re-entrantly. /// public sealed class RuntimeEntityObjectEventStream : IRuntimeEntityObjectEventSource, @@ -27,10 +29,12 @@ public sealed class RuntimeEntityObjectEventStream private readonly ClientObjectTable _objects; private readonly RuntimeEventSequencer _sequencer = new(); private readonly object _observerGate = new(); + private readonly List _pendingDispatch = []; private IRuntimeEntityObjectObserver[] _observers = []; private Func _generation = static () => default; private Func _frameNumber = static () => 0UL; private bool _contextBound; + private bool _dispatching; private bool _disposed; internal RuntimeEntityObjectEventStream( @@ -49,6 +53,8 @@ public sealed class RuntimeEntityObjectEventStream public ulong LastSequence => _sequencer.LastSequence; public int SubscriberCount => Volatile.Read(ref _observers).Length; + public int PendingDispatchCount => _pendingDispatch.Count; + public bool IsDispatching => _dispatching; public long DispatchFailureCount { get; private set; } public Exception? LastDispatchFailure { get; private set; } @@ -123,19 +129,7 @@ public sealed class RuntimeEntityObjectEventStream NextStamp(), change, RuntimeEntityObjectViews.Snapshot(record)); - IRuntimeEntityObjectObserver[] observers = - Volatile.Read(ref _observers); - foreach (IRuntimeEntityObjectObserver observer in observers) - { - try - { - observer.OnEntity(in delta); - } - catch (Exception error) - { - RecordDispatchFailure(error); - } - } + EnqueueAndDrain(PendingDispatch.ForEntity(delta)); } public void Dispose() @@ -154,6 +148,16 @@ public sealed class RuntimeEntityObjectEventStream } } + internal void DetachObservers() + { + lock (_observerGate) + { + if (_disposed) + return; + Volatile.Write(ref _observers, []); + } + } + private void OnObjectAdded(ClientObject item) => PublishInventory(RuntimeInventoryChange.Added, item); @@ -206,13 +210,46 @@ public sealed class RuntimeEntityObjectEventStream NextStamp(), change, item); + EnqueueAndDrain(PendingDispatch.ForInventory(delta)); + } + + private void EnqueueAndDrain(PendingDispatch pending) + { + _pendingDispatch.Add(pending); + if (_dispatching) + return; + + _dispatching = true; + try + { + for (int index = 0; index < _pendingDispatch.Count; index++) + Dispatch(_pendingDispatch[index]); + } + finally + { + _pendingDispatch.Clear(); + _dispatching = false; + } + } + + private void Dispatch(PendingDispatch pending) + { IRuntimeEntityObjectObserver[] observers = Volatile.Read(ref _observers); foreach (IRuntimeEntityObjectObserver observer in observers) { try { - observer.OnInventory(in delta); + if (pending.Kind is PendingDispatchKind.Entity) + { + RuntimeEntityDelta delta = pending.Entity; + observer.OnEntity(in delta); + } + else + { + RuntimeInventoryDelta delta = pending.Inventory; + observer.OnInventory(in delta); + } } catch (Exception error) { @@ -258,6 +295,26 @@ public sealed class RuntimeEntityObjectEventStream } } + private enum PendingDispatchKind : byte + { + Entity, + Inventory, + } + + private readonly record struct PendingDispatch( + PendingDispatchKind Kind, + RuntimeEntityDelta Entity, + RuntimeInventoryDelta Inventory) + { + public static PendingDispatch ForEntity( + RuntimeEntityDelta entity) => + new(PendingDispatchKind.Entity, entity, default); + + public static PendingDispatch ForInventory( + RuntimeInventoryDelta inventory) => + new(PendingDispatchKind.Inventory, default, inventory); + } + private sealed class ObserverSubscription( RuntimeEntityObjectEventStream owner, IRuntimeEntityObjectObserver observer) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 9c4eb41f..63764a8e 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -12,6 +12,27 @@ public readonly record struct RuntimeEntityRegistrationResult( bool ReplacedExistingGeneration, Exception? PriorGenerationCleanupFailure = null); +public readonly record struct RuntimeEntityObjectOwnershipSnapshot( + int ActiveEntityCount, + int TeardownEntityCount, + int ClaimedLocalIdCount, + int AcceptedSnapshotCount, + int UnresolvedParentRelationCount, + int StagedParentRelationCount, + int RecoveryParentRelationCount, + int CommittedParentRelationCount, + int ObjectCount, + int ContainerCount, + int ContainerProjectionCount, + int EquipmentOwnerCount, + int PendingMoveCount, + int StreamSubscriberCount, + long StreamDispatchFailureCount, + bool HasLastStreamDispatchFailure, + int PendingDispatchCount, + bool IsDispatching, + bool IsSessionClearInProgress); + /// /// One exact DeleteObject acceptance issued by a /// . The graphical host retires the @@ -66,6 +87,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public IRuntimeInventoryView InventoryView { get; } public RuntimeEntityObjectEventStream Events { get; } + public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() + { + ParentAttachmentState parents = Entities.ParentAttachments; + return new RuntimeEntityObjectOwnershipSnapshot( + Entities.Count, + Entities.PendingTeardownCount, + Entities.ClaimedLocalIdCount, + Entities.Snapshots.Count, + parents.UnresolvedRelationCount, + parents.StagedRelationCount, + parents.RecoveryRelationCount, + parents.CommittedRelationCount, + Objects.ObjectCount, + Objects.ContainerCount, + Objects.ContainerProjectionCount, + Objects.EquipmentOwnerCount, + Objects.PendingMoveCount, + Events.SubscriberCount, + Events.DispatchFailureCount, + Events.LastDispatchFailure is not null, + Events.PendingDispatchCount, + Events.IsDispatching, + _sessionClearInProgress); + } + public void BindEventContext( Func generation, Func frameNumber) @@ -181,6 +227,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Exception? cleanupFailure = null; if (prior is not null) { + // Removing the active GUID is an ownership transfer, not a gap. + // Retain the exact incarnation before arbitrary synchronous + // observers can re-enter registration, reset, or disposal. + Entities.RetainTeardown(prior); try { PublishEntity(RuntimeEntityChange.Deleted, prior); @@ -343,11 +393,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvanceObjDescAuthority(canonical); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Updated, canonical); - return Entities.IsCurrent(canonical); + ulong authorityVersion = canonical.ObjDescAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.ObjDescAuthorityVersion == authorityVersion); } public bool TryApplyPickup( @@ -370,11 +421,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); Entities.ParentAttachments.EndChildProjection(update.Guid); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Withdrawn, canonical); - return Entities.IsCurrent(canonical); + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Withdrawn, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion); } public bool TryApplyCreateParent( @@ -427,11 +481,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable } Entities.RefreshSnapshot(canonical, accepted); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Updated, canonical); - return Entities.IsCurrent(canonical); + Entities.AdvanceParentCommit(canonical); + ulong parentCommitVersion = canonical.ParentCommitVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.ParentCommitVersion + == parentCommitVersion); } public bool CommitAcceptedParentCellless( @@ -449,15 +506,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical) - || canonical.PositionAuthorityVersion != positionAuthorityVersion) - { - return false; - } - PublishEntity(RuntimeEntityChange.Withdrawn, canonical); - return Entities.IsCurrent(canonical) - && canonical.PositionAuthorityVersion == positionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Withdrawn, + () => canonical.PositionAuthorityVersion + == positionAuthorityVersion + && canonical.SpatialAuthorityVersion == spatialVersion); } public bool TryApplyMotion( @@ -483,11 +539,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, snapshot); if (applied && retainPayload) Entities.AdvanceMovementAuthority(canonical); + if (applied) + { + Entities.AdvanceMovementCommit(canonical); + ulong movementCommitVersion = + canonical.MovementCommitVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.MovementCommitVersion + == movementCommitVersion); + } + acknowledgeProjection?.Invoke(canonical); - if (applied && Entities.IsCurrent(canonical)) - PublishEntity(RuntimeEntityChange.Updated, canonical); - if (applied && !Entities.IsCurrent(canonical)) - return false; } return applied; @@ -510,11 +575,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvanceVectorAuthority(canonical); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Updated, canonical); - return Entities.IsCurrent(canonical); + ulong authorityVersion = canonical.VectorAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.VectorAuthorityVersion == authorityVersion); } public bool TryApplyState( @@ -539,16 +605,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable transition = Entities.ApplyRawPhysicsState( canonical, update.PhysicsState); - acknowledgeProjection?.Invoke(canonical, transition); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity( - transition.HiddenTransition + ulong stateVersion = canonical.StateAuthorityVersion; + ulong physicsMutationVersion = + canonical.PhysicsStateMutationVersion; + RetailPhysicsStateTransition committedTransition = transition; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke( + canonical, + committedTransition), + committedTransition.HiddenTransition is RetailHiddenTransition.BecameHidden ? RuntimeEntityChange.Hidden : RuntimeEntityChange.Updated, - canonical); - return Entities.IsCurrent(canonical); + () => canonical.StateAuthorityVersion == stateVersion + && canonical.PhysicsStateMutationVersion + == physicsMutationVersion); } public bool TryApplyPosition( @@ -611,18 +683,25 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.ParentAttachments.EndChildProjection(update.Guid); } - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - if (acceptedPosition) + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + if (!acceptedPosition) { - PublishEntity( - beforeCell != canonical.FullCellId - ? RuntimeEntityChange.Rebucketed - : RuntimeEntityChange.Updated, - canonical); + acknowledgeProjection?.Invoke(canonical); + return IsExpectedCanonical( + canonical, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion); } - return Entities.IsCurrent(canonical); + + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + beforeCell != canonical.FullCellId + ? RuntimeEntityChange.Rebucketed + : RuntimeEntityChange.Updated, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion); } public bool CommitRebucket( @@ -641,12 +720,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable canonical, fullCellId, canonicalLandblockId); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - if (previous != fullCellId) - PublishEntity(RuntimeEntityChange.Rebucketed, canonical); - return Entities.IsCurrent(canonical); + ulong spatialVersion = canonical.SpatialAuthorityVersion; + if (previous == fullCellId) + { + acknowledgeProjection?.Invoke(canonical); + return IsExpectedCanonical( + canonical, + () => canonical.SpatialAuthorityVersion + == spatialVersion); + } + + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Rebucketed, + () => canonical.SpatialAuthorityVersion == spatialVersion); } public bool CommitWithdrawal( @@ -660,11 +748,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.SuspendObjectClock(canonical); Entities.SetFullCell(canonical, 0u, 0u); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Withdrawn, canonical); - return Entities.IsCurrent(canonical); + ulong spatialVersion = canonical.SpatialAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Withdrawn, + () => canonical.SpatialAuthorityVersion == spatialVersion); } public bool CommitChildNoDraw( @@ -678,11 +767,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return false; Entities.SetChildNoDraw(canonical, noDraw); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Updated, canonical); - return Entities.IsCurrent(canonical); + ulong physicsMutationVersion = + canonical.PhysicsStateMutationVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.PhysicsStateMutationVersion + == physicsMutationVersion); } public bool RetireAfterProjectionAcquisitionFailure( @@ -694,6 +786,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable return false; Entities.AdvanceLifetimeMutation(canonical.ServerGuid); + Entities.RetainTeardown(canonical); PublishEntity(RuntimeEntityChange.Deleted, canonical); return true; } @@ -731,6 +824,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable && Entities.RemoveActive(active)) { retiredCanonical = active; + Entities.RetainTeardown(active); PublishEntity( removeRetainedObject ? RuntimeEntityChange.Deleted @@ -805,6 +899,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { if (!Entities.RemoveActive(canonical)) continue; + Entities.RetainTeardown(canonical); PublishEntity(RuntimeEntityChange.Deleted, canonical); } return retired; @@ -827,8 +922,52 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable { if (_disposed) return; - _disposed = true; - Events.Dispose(); + + // Disposal is terminal, unlike a session reset. Quiesce borrowers + // before committing internal cleanup so no observer can re-enter a + // lifetime whose owner is being destroyed. + Events.DetachObservers(); + List? failures = null; + try + { + if (!_sessionClearInProgress) + _ = BeginSessionClear(); + + try + { + ClearObjects(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + foreach (RuntimeEntityRecord canonical + in Entities.TeardownRecords.ToArray()) + { + Exception? failure = RetireCanonicalOnly(canonical); + if (failure is not null) + (failures ??= []).Add(failure); + } + + if (!CompleteSessionClearIfConverged()) + { + (failures ??= []).Add(new InvalidOperationException( + "Runtime entity/object disposal did not converge every canonical owner.")); + } + } + finally + { + _disposed = true; + Events.Dispose(); + } + + if (failures is not null) + { + throw new AggregateException( + "Runtime entity/object disposal failed to converge.", + failures); + } } private bool CommitPositionChannelUpdate( @@ -847,11 +986,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Entities.RefreshSnapshot(canonical, accepted); Entities.AdvancePositionAuthority(canonical); - acknowledgeProjection?.Invoke(canonical); - if (!Entities.IsCurrent(canonical)) - return false; - PublishEntity(RuntimeEntityChange.Updated, canonical); - return Entities.IsCurrent(canonical); + ulong positionVersion = canonical.PositionAuthorityVersion; + ulong spatialVersion = canonical.SpatialAuthorityVersion; + return AcknowledgeProjectionAndPublish( + canonical, + () => acknowledgeProjection?.Invoke(canonical), + RuntimeEntityChange.Updated, + () => canonical.PositionAuthorityVersion == positionVersion + && canonical.SpatialAuthorityVersion == spatialVersion); } private void PublishEntity( @@ -859,6 +1001,37 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable RuntimeEntityRecord canonical) => Events.PublishEntity(change, canonical); + private bool AcknowledgeProjectionAndPublish( + RuntimeEntityRecord canonical, + Action acknowledgeProjection, + RuntimeEntityChange change, + Func matchesCommittedMutation) + { + try + { + acknowledgeProjection(); + } + finally + { + if (IsExpectedCanonical( + canonical, + matchesCommittedMutation)) + { + PublishEntity(change, canonical); + } + } + + return IsExpectedCanonical( + canonical, + matchesCommittedMutation); + } + + private bool IsExpectedCanonical( + RuntimeEntityRecord canonical, + Func matchesCommittedMutation) => + Entities.IsCurrent(canonical) + && matchesCommittedMutation(); + private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs index d4c60879..fb3381df 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -50,6 +50,8 @@ public sealed class RuntimeEntityRecord public uint CanonicalLandblockId { get; internal set; } public uint RawPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; } + public ulong SpatialAuthorityVersion { get; private set; } + public ulong PhysicsStateMutationVersion { get; private set; } /// /// Incarnation-stable retail CPhysicsObj::update_time owner. @@ -72,6 +74,8 @@ public sealed class RuntimeEntityRecord public ulong VectorAuthorityVersion { get; private set; } public ulong VelocityAuthorityVersion { get; private set; } public ulong MovementAuthorityVersion { get; private set; } + public ulong MovementCommitVersion { get; private set; } = 1UL; + public ulong ParentCommitVersion { get; private set; } public ulong ObjDescAuthorityVersion { get; private set; } public ulong CreateIntegrationVersion { get; private set; } = 1UL; public bool DeleteAcceptedForTeardown { get; internal set; } @@ -94,6 +98,7 @@ public sealed class RuntimeEntityRecord internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) { StateAuthorityVersion++; + PhysicsStateMutationVersion++; RawPhysicsState = rawState; RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( FinalPhysicsState, @@ -123,6 +128,10 @@ public sealed class RuntimeEntityRecord VelocityAuthorityVersion++; } + internal void AdvanceMovementCommit() => MovementCommitVersion++; + + internal void AdvanceParentCommit() => ParentCommitVersion++; + internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++; internal void AdvanceCreateAuthority() @@ -138,6 +147,7 @@ public sealed class RuntimeEntityRecord internal void SetChildNoDraw(bool noDraw) { + PhysicsStateMutationVersion++; FinalPhysicsState = noDraw ? FinalPhysicsState | PhysicsStateFlags.NoDraw : FinalPhysicsState & ~PhysicsStateFlags.NoDraw; @@ -149,12 +159,22 @@ public sealed class RuntimeEntityRecord { if (refreshPosition && Snapshot.Position is { } position) { - FullCellId = position.LandblockId; - CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu; + SetFullCell( + position.LandblockId, + (position.LandblockId & 0xFFFF0000u) | 0xFFFFu); } RawPhysicsState = Snapshot.Physics?.RawState ?? Snapshot.PhysicsState ?? 0u; } + + internal void SetFullCell( + uint fullCellId, + uint canonicalLandblockId) + { + SpatialAuthorityVersion++; + FullCellId = fullCellId; + CanonicalLandblockId = canonicalLandblockId; + } } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index c7212c25..a2076439 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -195,6 +195,32 @@ public sealed class CurrentGameRuntimeAdapterTests spawn.Guid, Harness.PlayerGuid, newSlot: 3); + var objDesc = new ObjDescEvent.Parsed( + spawn.Guid, + new CreateObject.ModelData( + 0x04000001u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + spawn.InstanceSequence, + ObjDescSequence: 2); + Assert.True(direct.TryApplyObjDesc( + objDesc, + acknowledgeProjection: null, + out _)); + var motion = new WorldSession.EntityMotionUpdate( + spawn.Guid, + new CreateObject.ServerMotionState(0x3D, 0x11), + spawn.InstanceSequence, + MovementSequence: 2, + ServerControlSequence: 2, + IsAutonomous: false); + Assert.True(direct.TryApplyMotion( + motion, + retainPayload: true, + acknowledgeProjection: null, + out _, + out _)); var vector = new VectorUpdate.Parsed( spawn.Guid, new Vector3(1f, 2f, 3f), @@ -216,6 +242,12 @@ public sealed class CurrentGameRuntimeAdapterTests acknowledgeProjection: null, out _, out _)); + Assert.True(direct.CommitChildNoDraw( + directCanonical, + noDraw: true)); + Assert.True(direct.CommitChildNoDraw( + directCanonical, + noDraw: false)); WorldSession.EntityPositionUpdate position = Position(spawn.Guid, spawn.InstanceSequence); Assert.True(direct.TryApplyPosition( @@ -228,6 +260,10 @@ public sealed class CurrentGameRuntimeAdapterTests out _, out _, out _)); + Assert.True(direct.CommitRebucket( + directCanonical, + 0x12360001u, + 0x1236FFFFu)); Assert.True(direct.TryApplyPickup( new PickupEvent.Parsed( spawn.Guid, @@ -253,8 +289,22 @@ public sealed class CurrentGameRuntimeAdapterTests spawn.Guid, Harness.PlayerGuid, newSlot: 3); + Assert.True(graphical.Entities.TryApplyObjDesc( + objDesc, + out _)); + Assert.True(graphical.Entities.TryApplyMotion( + motion, + retainPayload: true, + out _, + out _)); Assert.True(graphical.Entities.TryApplyVector(vector, out _)); Assert.True(graphical.Entities.TryApplyState(state, out _, out _)); + Assert.True(graphical.Entities.SetAttachedChildNoDraw( + spawn.Guid, + noDraw: true)); + Assert.True(graphical.Entities.SetAttachedChildNoDraw( + spawn.Guid, + noDraw: false)); Assert.True(graphical.Entities.TryApplyPosition( position, isLocalPlayer: false, @@ -263,6 +313,9 @@ public sealed class CurrentGameRuntimeAdapterTests out _, out _, out _)); + Assert.True(graphical.Entities.RebucketLiveEntity( + spawn.Guid, + 0x12360001u)); Assert.True(graphical.Entities.TryApplyPickup( new PickupEvent.Parsed( spawn.Guid, diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 0edaa3a9..c097c2ae 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -9,6 +9,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -244,6 +245,7 @@ public sealed class LiveEntityLifecycleStressTests fixture.Effects.ClearNetworkState(); Assert.Equal(0, fixture.Runtime.Count); Assert.Equal(0, fixture.Effects.PendingPacketCount); + AssertOwnershipLedgerConverged(fixture); } [Fact] @@ -299,6 +301,98 @@ public sealed class LiveEntityLifecycleStressTests Assert.Equal(0, fixture.Spatial.PendingBucketCount); } + private static void AssertOwnershipLedgerConverged(Fixture fixture) + { + RuntimeEntityObjectOwnershipSnapshot canonical = + fixture.EntityObjects.CaptureOwnership(); + var ledger = new J3OwnershipLedger( + canonical, + fixture.Runtime.MaterializedCount, + fixture.Runtime.VisibleRecords.Count, + fixture.Runtime.AnimationRuntimeCount, + fixture.Runtime.SpatialAnimationRuntimeCount, + fixture.Runtime.SpatialRemoteMotionRuntimeCount, + fixture.Runtime.SpatialProjectileRuntimeCount, + fixture.Runtime.SpatialRootObjectCount, + fixture.RenderOwners.Count, + fixture.RenderRegisterCount - fixture.RenderUnregisterCount, + fixture.Projectiles.Count, + fixture.Effects.ReadyOwnerCount, + fixture.Effects.PendingPacketCount, + fixture.Runner.ActiveOwnerCount, + fixture.Runner.ActiveScriptCount, + fixture.Runner.ScheduledCallPesCount, + fixture.Runner.OwnerAnchorCount, + fixture.Particles.ActiveEmitterCount, + fixture.Particles.ActiveParticleCount, + fixture.ParticleSink.ActiveBindingCount, + fixture.ParticleSink.LogicalEmitterCount, + fixture.ParticleSink.TrackedOwnerCount, + fixture.ParticleSink.RenderPassOwnerCount, + fixture.ParticleSink.HiddenPresentationOwnerCount, + fixture.Poses.Count, + fixture.Lights.RegisteredCount, + fixture.LiveLights.TrackedOwnerCount, + fixture.LiveLights.PresentedOwnerCount, + fixture.Lighting.OwnedLightOwnerCount, + fixture.Lighting.PoseTrackedOwnerCount, + fixture.Lighting.RetainedOwnerStateCount, + fixture.Engine.ShadowObjects.TotalRegistered, + fixture.Engine.ShadowObjects.RetainedRegistrationCount, + fixture.Engine.ShadowObjects.SuspendedRegistrationCount, + fixture.Spatial.PendingLiveEntityCount, + fixture.Spatial.PendingBucketCount, + fixture.Spatial.PendingRescueCount, + fixture.Spatial.PersistentGuidCount, + fixture.Spatial.PendingVisibilityTransitionCount, + fixture.Spatial.Entities.Count(entity => + entity.ServerGuid != 0u)); + + Assert.Equal(default(J3OwnershipLedger), ledger); + } + + private readonly record struct J3OwnershipLedger( + RuntimeEntityObjectOwnershipSnapshot Canonical, + int AppMaterializedCount, + int AppVisibleCount, + int AnimationRuntimeCount, + int SpatialAnimationRuntimeCount, + int SpatialRemoteMotionRuntimeCount, + int SpatialProjectileRuntimeCount, + int SpatialRootObjectCount, + int RenderOwnerCount, + int RenderRegistrationDelta, + int ProjectileCount, + int EffectReadyOwnerCount, + int EffectPendingPacketCount, + int ScriptOwnerCount, + int ActiveScriptCount, + int ScheduledCallPesCount, + int ScriptAnchorCount, + int EmitterCount, + int ParticleCount, + int ParticleBindingCount, + int LogicalEmitterCount, + int ParticleTrackedOwnerCount, + int ParticleRenderPassOwnerCount, + int HiddenParticlePresentationCount, + int PoseCount, + int RegisteredLightCount, + int TrackedLiveLightCount, + int PresentedLiveLightCount, + int OwnedLightCount, + int LightPoseCount, + int RetainedLightStateCount, + int ShadowCount, + int RetainedShadowCount, + int SuspendedShadowCount, + int PendingSpatialEntityCount, + int PendingBucketCount, + int PendingRescueCount, + int PersistentGuidCount, + int PendingVisibilityCount, + int MaterializedWorldEntityCount); + private sealed class Fixture : IDisposable { private readonly Dictionary _scripts = new(); @@ -328,7 +422,8 @@ public sealed class LiveEntityLifecycleStressTests randomUnit: () => 0.5, canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); - Runtime = LiveEntityRuntimeFixture.Create( + EntityObjects = new RuntimeEntityObjectLifetime(); + Runtime = new LiveEntityRuntime( Spatial, new DelegateLiveEntityResourceLifecycle( entity => @@ -358,7 +453,8 @@ public sealed class LiveEntityLifecycleStressTests cleanups.Add(() => _liveLights?.Forget(record)); } LiveEntityTeardown.Run(cleanups); - }); + }, + EntityObjects); Effects = new EntityEffectController( Runtime, @@ -386,6 +482,7 @@ public sealed class LiveEntityLifecycleStressTests internal LightManager Lights { get; } = new(); internal LightingHookSink Lighting { get; } internal PhysicsScriptRunner Runner { get; } + internal RuntimeEntityObjectLifetime EntityObjects { get; } internal LiveEntityRuntime Runtime { get; } internal EntityEffectController Effects { get; } internal ProjectileController Projectiles { get; } diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs index f48b8f02..bc6a3968 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs @@ -393,6 +393,124 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.Equal(1, lifetime.Events.SubscriberCount); } + [Fact] + public void ReentrantCrossDomainCommit_PreservesSequenceForEveryObserver() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(6), static () => 12UL); + bool nested = false; + var mutating = new CallbackObserver( + onEntity: _ => + { + if (nested) + return; + nested = true; + lifetime.Objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x71000001u, + Name = "nested", + }); + }); + var recording = new RecordingObserver(); + using IDisposable first = lifetime.Events.Subscribe(mutating); + using IDisposable second = lifetime.Events.Subscribe(recording); + + _ = lifetime.RegisterEntity( + Spawn(0x7000001Au, 1, "outer")); + + Assert.Equal( + [ + (RuntimeTraceKind.Entity, 1UL), + (RuntimeTraceKind.Inventory, 2UL), + ], + recording.Order); + Assert.Equal(0, lifetime.Events.PendingDispatchCount); + Assert.False(lifetime.Events.IsDispatching); + } + + [Fact] + public void ObjectTransactions_PublishCommittedBorrowedViewInExactOrder() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(7), static () => 13UL); + const uint itemId = 0x71000002u; + var observedViews = + new List(); + var observer = new CallbackObserver( + onInventory: delta => + { + if (delta.Change + is RuntimeInventoryChange.Removed + or RuntimeInventoryChange.Cleared) + { + return; + } + + Assert.True(lifetime.InventoryView.TryGet( + delta.Item.ObjectId, + out RuntimeInventoryItemSnapshot current)); + observedViews.Add(current); + }); + var recording = new RecordingObserver(); + using IDisposable first = lifetime.Events.Subscribe(observer); + using IDisposable second = lifetime.Events.Subscribe(recording); + + lifetime.Objects.AddOrUpdate(new ClientObject + { + ObjectId = itemId, + Name = "transaction", + ContainerId = 0x50000001u, + ContainerSlot = 2, + StackSize = 3, + Value = 30, + }); + Assert.True(lifetime.Objects.MoveItemOptimistic( + itemId, + 0x50000002u, + newSlot: 0)); + Assert.True(lifetime.Objects.RollbackMove(itemId)); + Assert.True(lifetime.Objects.UpdateStackSize( + itemId, + stackSize: 2, + value: 20)); + Assert.True(lifetime.Objects.RemoveLogicalGeneration( + itemId, + generation: 9)); + lifetime.Objects.AddOrUpdate(new ClientObject + { + ObjectId = itemId, + Name = "replacement", + }); + lifetime.ClearObjects(); + + Assert.Equal( + [ + RuntimeInventoryChange.Added, + RuntimeInventoryChange.Moved, + RuntimeInventoryChange.Moved, + RuntimeInventoryChange.Updated, + RuntimeInventoryChange.Removed, + RuntimeInventoryChange.Added, + RuntimeInventoryChange.Cleared, + ], + recording.Inventory + .Select(delta => delta.Change) + .ToArray()); + Assert.Equal( + Enumerable.Range(1, 7).Select(value => (ulong)value), + recording.Inventory.Select(delta => delta.Stamp.Sequence)); + Assert.Equal( + (ushort)9, + recording.Inventory[4].Item.Incarnation); + Assert.Equal(5, observedViews.Count); + Assert.Equal(0x50000002u, observedViews[1].ContainerId); + Assert.Equal(0x50000001u, observedViews[2].ContainerId); + Assert.Equal(2, observedViews[3].StackSize); + Assert.Equal("replacement", observedViews[4].Name); + Assert.Equal(0, lifetime.InventoryView.ObjectCount); + Assert.Equal(0, lifetime.Events.PendingDispatchCount); + } + [Fact] public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval() { @@ -530,6 +648,97 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.Equal(0, lifetime.Entities.PendingTeardownCount); } + [Fact] + public void GenerationReplacement_RetainsExactReceiptBeforeTerminalCallback() + { + var lifetime = new RuntimeEntityObjectLifetime(); + const uint guid = 0x70000030u; + RuntimeEntityRecord prior = + lifetime.RegisterEntity(Spawn(guid, 1, "prior")).Canonical!; + uint priorLocalId = prior.LocalEntityId!.Value; + bool observedRetainedReceipt = false; + var observer = new CallbackObserver( + onEntity: delta => + { + if (delta.Change is not RuntimeEntityChange.Deleted) + return; + observedRetainedReceipt = lifetime.Entities.TryGetTeardown( + guid, + 1, + out RuntimeEntityRecord retained) + && ReferenceEquals(prior, retained) + && lifetime.Entities.TryGetByLocalId( + priorLocalId, + out RuntimeEntityRecord identity) + && ReferenceEquals(prior, identity); + }); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + + RuntimeEntityRegistrationResult replacement = + lifetime.RegisterEntity(Spawn(guid, 2, "replacement")); + + Assert.True(observedRetainedReceipt); + Assert.Equal((ushort)2, replacement.Canonical!.Incarnation); + Assert.Equal(0, lifetime.Entities.PendingTeardownCount); + Assert.Equal(1, lifetime.Entities.ClaimedLocalIdCount); + Assert.False(lifetime.Entities.TryGetByLocalId( + priorLocalId, + out _)); + } + + [Fact] + public void DeleteAndSessionClear_ExposeExactReceiptsUntilAcknowledged() + { + var lifetime = new RuntimeEntityObjectLifetime(); + const uint deletedGuid = 0x70000031u; + const uint clearedGuid = 0x70000032u; + RuntimeEntityRecord deleted = lifetime.RegisterEntity( + Spawn(deletedGuid, 1, "deleted")).Canonical!; + RuntimeEntityRecord cleared = lifetime.RegisterEntity( + Spawn(clearedGuid, 1, "cleared")).Canonical!; + var retainedDuringCallbacks = new HashSet(); + var observer = new CallbackObserver( + onEntity: delta => + { + if (delta.Change + is RuntimeEntityChange.Deleted + or RuntimeEntityChange.Withdrawn + && lifetime.Entities.TryGetTeardown( + delta.Entity.Identity.ServerGuid, + delta.Entity.Identity.Incarnation, + out _)) + { + retainedDuringCallbacks.Add( + delta.Entity.Identity.ServerGuid); + } + }); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(deletedGuid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance)); + Assert.Contains(deletedGuid, retainedDuringCallbacks); + Assert.Equal(1, lifetime.Entities.PendingTeardownCount); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(deleted)); + + IReadOnlyList retired = + lifetime.BeginSessionClear(); + Assert.Same(cleared, Assert.Single(retired)); + Assert.Contains(clearedGuid, retainedDuringCallbacks); + Assert.Equal(1, lifetime.Entities.PendingTeardownCount); + Assert.Null(lifetime.RetireCanonicalOnly(cleared)); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + Assert.Equal( + default(RuntimeEntityObjectOwnershipSnapshot) with + { + StreamSubscriberCount = 1, + }, + lifetime.CaptureOwnership()); + } + [Fact] public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate() { @@ -685,6 +894,274 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.False(lifetime.Entities.TryGetActive(guid, out _)); } + [Fact] + public void AcceptedVector_ProjectionFailureStillPublishesCommittedFact() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(21), static () => 90UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + const uint guid = 0x70000024u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, 1, "vector")).Canonical!; + var velocity = new System.Numerics.Vector3(3f, 4f, 5f); + + Assert.Throws(() => + lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + velocity, + System.Numerics.Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2), + _ => throw new InvalidOperationException( + "projection failed after canonical commit"), + out _)); + + Assert.True(lifetime.Entities.IsCurrent(canonical)); + Assert.Equal(velocity, canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal( + [ + RuntimeEntityChange.Registered, + RuntimeEntityChange.Updated, + ], + observer.Entities.Select(delta => delta.Change).ToArray()); + Assert.Equal([1UL, 2UL], observer.Stamps + .Select(stamp => stamp.Sequence) + .ToArray()); + } + + [Fact] + public void ReentrantNewerVector_SupersedesOuterCommitPublication() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(22), static () => 91UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + const uint guid = 0x70000025u; + _ = lifetime.RegisterEntity(Spawn(guid, 1, "vector")); + var newest = new System.Numerics.Vector3(9f, 8f, 7f); + + bool outerRemainedCurrent = lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + new System.Numerics.Vector3(1f, 2f, 3f), + System.Numerics.Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2), + ignoredCanonical => Assert.True(lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + newest, + System.Numerics.Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 3), + acknowledgeProjection: null, + out _)), + out _); + + Assert.False(outerRemainedCurrent); + Assert.True(lifetime.Entities.TryGetActive( + guid, + out RuntimeEntityRecord current)); + Assert.Equal(newest, current.Snapshot.Physics!.Value.Velocity); + Assert.Equal( + [ + RuntimeEntityChange.Registered, + RuntimeEntityChange.Updated, + ], + observer.Entities.Select(delta => delta.Change).ToArray()); + Assert.Equal(2UL, lifetime.Events.LastSequence); + } + + [Fact] + public void ReentrantTimestampOnlyMotion_SupersedesOuterPublication() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(26), static () => 95UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + const uint guid = 0x7000002Bu; + _ = lifetime.RegisterEntity(Spawn(guid, 1, "motion")); + var newestMotion = + new CreateObject.ServerMotionState(0x3D, 0x12); + + bool outerRemainedCurrent = lifetime.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3D, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: true), + retainPayload: false, + ignoredCanonical => Assert.True(lifetime.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + guid, + newestMotion, + InstanceSequence: 1, + MovementSequence: 3, + ServerControlSequence: 1, + IsAutonomous: true), + retainPayload: false, + acknowledgeProjection: null, + out _, + out _)), + out _, + out _); + + Assert.False(outerRemainedCurrent); + Assert.True(lifetime.Entities.TryGetActive( + guid, + out RuntimeEntityRecord current)); + Assert.NotEqual( + newestMotion, + current.Snapshot.MotionState); + Assert.Equal( + [ + RuntimeEntityChange.Registered, + RuntimeEntityChange.Updated, + ], + observer.Entities.Select(delta => delta.Change).ToArray()); + Assert.Equal(2UL, lifetime.Events.LastSequence); + } + + [Fact] + public void Rebucket_ProjectionFailureStillPublishesCommittedCell() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(23), static () => 92UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + RuntimeEntityRecord canonical = lifetime.RegisterEntity( + Spawn(0x70000026u, 1, "rebucket")).Canonical!; + + Assert.Throws(() => + lifetime.CommitRebucket( + canonical, + 0x02020022u, + 0x0202FFFFu, + _ => throw new InvalidOperationException( + "projection failed after spatial commit"))); + + Assert.Equal(0x02020022u, canonical.FullCellId); + RuntimeEntityDelta committed = observer.Entities[^1]; + Assert.Equal(RuntimeEntityChange.Rebucketed, committed.Change); + Assert.Equal(0x02020022u, committed.Entity.CellId); + Assert.Equal(2UL, committed.Stamp.Sequence); + } + + [Fact] + public void ReentrantChildStateMutation_SupersedesOuterPublication() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(24), static () => 93UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + RuntimeEntityRecord canonical = lifetime.RegisterEntity( + Spawn(0x70000027u, 1, "child")).Canonical!; + + bool outerRemainedCurrent = lifetime.CommitChildNoDraw( + canonical, + noDraw: true, + _ => Assert.True(lifetime.CommitChildNoDraw( + canonical, + noDraw: false))); + + Assert.False(outerRemainedCurrent); + Assert.Equal( + PhysicsStateFlags.None, + canonical.FinalPhysicsState & PhysicsStateFlags.NoDraw); + Assert.Equal( + [ + RuntimeEntityChange.Registered, + RuntimeEntityChange.Updated, + ], + observer.Entities.Select(delta => delta.Change).ToArray()); + Assert.Equal(2UL, lifetime.Events.LastSequence); + } + + [Fact] + public void RejectedUpdate_ConsumesNoSharedSequence() + { + var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new(25), static () => 94UL); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + const uint guid = 0x70000028u; + _ = lifetime.RegisterEntity(Spawn(guid, 1, "stale")); + + Assert.False(lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + new System.Numerics.Vector3(1f, 0f, 0f), + System.Numerics.Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 1), + acknowledgeProjection: null, + out _)); + + Assert.Single(observer.Entities); + Assert.Equal(1UL, lifetime.Events.LastSequence); + } + + [Fact] + public void DisposeWithLiveDirectState_ConvergesCompleteOwnershipLedger() + { + var lifetime = new RuntimeEntityObjectLifetime(); + const uint guid = 0x70000029u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, 1, "dispose")).Canonical!; + Assert.True(lifetime.ApplyAcceptedSpawn( + canonical, + canonical.CreateIntegrationVersion, + canonical.Snapshot, + replaceGeneration: false)); + _ = lifetime.Events.Subscribe(new RecordingObserver()); + + lifetime.Dispose(); + lifetime.Dispose(); + + Assert.Equal( + default(RuntimeEntityObjectOwnershipSnapshot), + lifetime.CaptureOwnership()); + } + + [Fact] + public void DisposeAfterAcceptedDelete_ConvergesUnfinishedReceiptAndRelations() + { + var lifetime = new RuntimeEntityObjectLifetime(); + const uint guid = 0x7000002Au; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, 1, "pending")).Canonical!; + Assert.True(lifetime.ApplyAcceptedSpawn( + canonical, + canonical.CreateIntegrationVersion, + canonical.Snapshot, + replaceGeneration: false)); + lifetime.Entities.ParentAttachments.Enqueue( + new ParentEvent.Parsed( + ParentGuid: 0x7000F001u, + ChildGuid: guid, + ParentLocation: 1, + PlacementId: 2, + ParentInstanceSequence: 4, + ChildPositionSequence: 3)); + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(1, lifetime.Entities.PendingTeardownCount); + Assert.Equal(1, lifetime.Objects.ObjectCount); + + lifetime.Dispose(); + + Assert.Equal( + default(RuntimeEntityObjectOwnershipSnapshot), + lifetime.CaptureOwnership()); + } + private static RuntimeEntityRecord Accept( RuntimeEntityObjectLifetime lifetime, WorldSession.EntitySpawn spawn)