harden(runtime): close canonical entity object lifetime

Retain exact teardown receipts before terminal callbacks, preserve ordered re-entrant entity/object publication, publish committed facts across projection failures, and make direct disposal converge every canonical owner. Add a complete ownership ledger plus adversarial direct/graphical parity, callback, GUID reuse, reset, and resource-churn gates.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 07:13:42 +02:00
parent 84954c8b77
commit 119b7c1151
9 changed files with 992 additions and 95 deletions

View file

@ -201,6 +201,9 @@ public sealed class ClientObjectTable
public int ObjectCount => _objects.Count; public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count; public int ContainerCount => _containers.Count;
public int ContainerProjectionCount => _containerIndex.Count;
public int EquipmentOwnerCount => _equipmentIndex.Count;
public int PendingMoveCount => _pendingMoves.Count;
public IEnumerable<ClientObject> Objects => _objects.Values; public IEnumerable<ClientObject> Objects => _objects.Values;
public IEnumerable<Container> Containers => _containers.Values; public IEnumerable<Container> Containers => _containers.Values;

View file

@ -17,6 +17,12 @@ public sealed class ParentAttachmentState
private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new(); private readonly Dictionary<uint, ParentAttachmentRelation> _recoveryByChild = new();
private readonly Dictionary<uint, ParentAttachmentRelation> _lastAcceptedByChild = new(); private readonly Dictionary<uint, ParentAttachmentRelation> _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) public void AcceptCreateObjectRelation(ParentAttachmentRelation relation)
{ {
_stagedByChild[relation.ChildGuid] = relation; _stagedByChild[relation.ChildGuid] = relation;

View file

@ -247,6 +247,18 @@ public sealed class RuntimeEntityDirectory
record.AdvanceMovementAuthority(); 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) public void AdvanceObjDescAuthority(RuntimeEntityRecord record)
{ {
EnsureKnown(record); EnsureKnown(record);
@ -293,8 +305,7 @@ public sealed class RuntimeEntityDirectory
uint canonicalLandblockId) uint canonicalLandblockId)
{ {
EnsureKnown(record); EnsureKnown(record);
record.FullCellId = fullCellId; record.SetFullCell(fullCellId, canonicalLandblockId);
record.CanonicalLandblockId = canonicalLandblockId;
} }
public void SetFinalPhysicsState( public void SetFinalPhysicsState(

View file

@ -16,8 +16,10 @@ public interface IRuntimeEntityObjectEventSource
/// <summary> /// <summary>
/// One synchronous, generation-stamped commit stream for the canonical entity /// One synchronous, generation-stamped commit stream for the canonical entity
/// directory and retained object table. It owns no queue and dispatches on the /// directory and retained object table. It owns no cross-frame queue and
/// caller's update/network-drain thread. /// 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.
/// </summary> /// </summary>
public sealed class RuntimeEntityObjectEventStream public sealed class RuntimeEntityObjectEventStream
: IRuntimeEntityObjectEventSource, : IRuntimeEntityObjectEventSource,
@ -27,10 +29,12 @@ public sealed class RuntimeEntityObjectEventStream
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly RuntimeEventSequencer _sequencer = new(); private readonly RuntimeEventSequencer _sequencer = new();
private readonly object _observerGate = new(); private readonly object _observerGate = new();
private readonly List<PendingDispatch> _pendingDispatch = [];
private IRuntimeEntityObjectObserver[] _observers = []; private IRuntimeEntityObjectObserver[] _observers = [];
private Func<RuntimeGenerationToken> _generation = static () => default; private Func<RuntimeGenerationToken> _generation = static () => default;
private Func<ulong> _frameNumber = static () => 0UL; private Func<ulong> _frameNumber = static () => 0UL;
private bool _contextBound; private bool _contextBound;
private bool _dispatching;
private bool _disposed; private bool _disposed;
internal RuntimeEntityObjectEventStream( internal RuntimeEntityObjectEventStream(
@ -49,6 +53,8 @@ public sealed class RuntimeEntityObjectEventStream
public ulong LastSequence => _sequencer.LastSequence; public ulong LastSequence => _sequencer.LastSequence;
public int SubscriberCount => Volatile.Read(ref _observers).Length; public int SubscriberCount => Volatile.Read(ref _observers).Length;
public int PendingDispatchCount => _pendingDispatch.Count;
public bool IsDispatching => _dispatching;
public long DispatchFailureCount { get; private set; } public long DispatchFailureCount { get; private set; }
public Exception? LastDispatchFailure { get; private set; } public Exception? LastDispatchFailure { get; private set; }
@ -123,19 +129,7 @@ public sealed class RuntimeEntityObjectEventStream
NextStamp(), NextStamp(),
change, change,
RuntimeEntityObjectViews.Snapshot(record)); RuntimeEntityObjectViews.Snapshot(record));
IRuntimeEntityObjectObserver[] observers = EnqueueAndDrain(PendingDispatch.ForEntity(delta));
Volatile.Read(ref _observers);
foreach (IRuntimeEntityObjectObserver observer in observers)
{
try
{
observer.OnEntity(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
} }
public void Dispose() 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) => private void OnObjectAdded(ClientObject item) =>
PublishInventory(RuntimeInventoryChange.Added, item); PublishInventory(RuntimeInventoryChange.Added, item);
@ -206,13 +210,46 @@ public sealed class RuntimeEntityObjectEventStream
NextStamp(), NextStamp(),
change, change,
item); 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 = IRuntimeEntityObjectObserver[] observers =
Volatile.Read(ref _observers); Volatile.Read(ref _observers);
foreach (IRuntimeEntityObjectObserver observer in observers) foreach (IRuntimeEntityObjectObserver observer in observers)
{ {
try 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) 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( private sealed class ObserverSubscription(
RuntimeEntityObjectEventStream owner, RuntimeEntityObjectEventStream owner,
IRuntimeEntityObjectObserver observer) IRuntimeEntityObjectObserver observer)

View file

@ -12,6 +12,27 @@ public readonly record struct RuntimeEntityRegistrationResult(
bool ReplacedExistingGeneration, bool ReplacedExistingGeneration,
Exception? PriorGenerationCleanupFailure = null); 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);
/// <summary> /// <summary>
/// One exact DeleteObject acceptance issued by a /// One exact DeleteObject acceptance issued by a
/// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the /// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the
@ -66,6 +87,31 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
public IRuntimeInventoryView InventoryView { get; } public IRuntimeInventoryView InventoryView { get; }
public RuntimeEntityObjectEventStream Events { 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( public void BindEventContext(
Func<RuntimeGenerationToken> generation, Func<RuntimeGenerationToken> generation,
Func<ulong> frameNumber) Func<ulong> frameNumber)
@ -181,6 +227,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Exception? cleanupFailure = null; Exception? cleanupFailure = null;
if (prior is not 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 try
{ {
PublishEntity(RuntimeEntityChange.Deleted, prior); PublishEntity(RuntimeEntityChange.Deleted, prior);
@ -343,11 +393,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, accepted); Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvanceObjDescAuthority(canonical); Entities.AdvanceObjDescAuthority(canonical);
acknowledgeProjection?.Invoke(canonical); ulong authorityVersion = canonical.ObjDescAuthorityVersion;
if (!Entities.IsCurrent(canonical)) return AcknowledgeProjectionAndPublish(
return false; canonical,
PublishEntity(RuntimeEntityChange.Updated, canonical); () => acknowledgeProjection?.Invoke(canonical),
return Entities.IsCurrent(canonical); RuntimeEntityChange.Updated,
() => canonical.ObjDescAuthorityVersion == authorityVersion);
} }
public bool TryApplyPickup( public bool TryApplyPickup(
@ -370,11 +421,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.SuspendObjectClock(canonical); Entities.SuspendObjectClock(canonical);
Entities.SetFullCell(canonical, 0u, 0u); Entities.SetFullCell(canonical, 0u, 0u);
Entities.ParentAttachments.EndChildProjection(update.Guid); Entities.ParentAttachments.EndChildProjection(update.Guid);
acknowledgeProjection?.Invoke(canonical); ulong positionVersion = canonical.PositionAuthorityVersion;
if (!Entities.IsCurrent(canonical)) ulong spatialVersion = canonical.SpatialAuthorityVersion;
return false; return AcknowledgeProjectionAndPublish(
PublishEntity(RuntimeEntityChange.Withdrawn, canonical); canonical,
return Entities.IsCurrent(canonical); () => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Withdrawn,
() => canonical.PositionAuthorityVersion == positionVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
} }
public bool TryApplyCreateParent( public bool TryApplyCreateParent(
@ -427,11 +481,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
} }
Entities.RefreshSnapshot(canonical, accepted); Entities.RefreshSnapshot(canonical, accepted);
acknowledgeProjection?.Invoke(canonical); Entities.AdvanceParentCommit(canonical);
if (!Entities.IsCurrent(canonical)) ulong parentCommitVersion = canonical.ParentCommitVersion;
return false; return AcknowledgeProjectionAndPublish(
PublishEntity(RuntimeEntityChange.Updated, canonical); canonical,
return Entities.IsCurrent(canonical); () => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Updated,
() => canonical.ParentCommitVersion
== parentCommitVersion);
} }
public bool CommitAcceptedParentCellless( public bool CommitAcceptedParentCellless(
@ -449,15 +506,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.SuspendObjectClock(canonical); Entities.SuspendObjectClock(canonical);
Entities.SetFullCell(canonical, 0u, 0u); Entities.SetFullCell(canonical, 0u, 0u);
acknowledgeProjection?.Invoke(canonical); ulong spatialVersion = canonical.SpatialAuthorityVersion;
if (!Entities.IsCurrent(canonical) return AcknowledgeProjectionAndPublish(
|| canonical.PositionAuthorityVersion != positionAuthorityVersion) canonical,
{ () => acknowledgeProjection?.Invoke(canonical),
return false; RuntimeEntityChange.Withdrawn,
} () => canonical.PositionAuthorityVersion
PublishEntity(RuntimeEntityChange.Withdrawn, canonical); == positionAuthorityVersion
return Entities.IsCurrent(canonical) && canonical.SpatialAuthorityVersion == spatialVersion);
&& canonical.PositionAuthorityVersion == positionAuthorityVersion;
} }
public bool TryApplyMotion( public bool TryApplyMotion(
@ -483,11 +539,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, snapshot); Entities.RefreshSnapshot(canonical, snapshot);
if (applied && retainPayload) if (applied && retainPayload)
Entities.AdvanceMovementAuthority(canonical); 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); acknowledgeProjection?.Invoke(canonical);
if (applied && Entities.IsCurrent(canonical))
PublishEntity(RuntimeEntityChange.Updated, canonical);
if (applied && !Entities.IsCurrent(canonical))
return false;
} }
return applied; return applied;
@ -510,11 +575,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, accepted); Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvanceVectorAuthority(canonical); Entities.AdvanceVectorAuthority(canonical);
acknowledgeProjection?.Invoke(canonical); ulong authorityVersion = canonical.VectorAuthorityVersion;
if (!Entities.IsCurrent(canonical)) return AcknowledgeProjectionAndPublish(
return false; canonical,
PublishEntity(RuntimeEntityChange.Updated, canonical); () => acknowledgeProjection?.Invoke(canonical),
return Entities.IsCurrent(canonical); RuntimeEntityChange.Updated,
() => canonical.VectorAuthorityVersion == authorityVersion);
} }
public bool TryApplyState( public bool TryApplyState(
@ -539,16 +605,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
transition = Entities.ApplyRawPhysicsState( transition = Entities.ApplyRawPhysicsState(
canonical, canonical,
update.PhysicsState); update.PhysicsState);
acknowledgeProjection?.Invoke(canonical, transition); ulong stateVersion = canonical.StateAuthorityVersion;
if (!Entities.IsCurrent(canonical)) ulong physicsMutationVersion =
return false; canonical.PhysicsStateMutationVersion;
PublishEntity( RetailPhysicsStateTransition committedTransition = transition;
transition.HiddenTransition return AcknowledgeProjectionAndPublish(
canonical,
() => acknowledgeProjection?.Invoke(
canonical,
committedTransition),
committedTransition.HiddenTransition
is RetailHiddenTransition.BecameHidden is RetailHiddenTransition.BecameHidden
? RuntimeEntityChange.Hidden ? RuntimeEntityChange.Hidden
: RuntimeEntityChange.Updated, : RuntimeEntityChange.Updated,
canonical); () => canonical.StateAuthorityVersion == stateVersion
return Entities.IsCurrent(canonical); && canonical.PhysicsStateMutationVersion
== physicsMutationVersion);
} }
public bool TryApplyPosition( public bool TryApplyPosition(
@ -611,18 +683,25 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.ParentAttachments.EndChildProjection(update.Guid); Entities.ParentAttachments.EndChildProjection(update.Guid);
} }
acknowledgeProjection?.Invoke(canonical); ulong positionVersion = canonical.PositionAuthorityVersion;
if (!Entities.IsCurrent(canonical)) ulong spatialVersion = canonical.SpatialAuthorityVersion;
return false; if (!acceptedPosition)
if (acceptedPosition)
{ {
PublishEntity( acknowledgeProjection?.Invoke(canonical);
beforeCell != canonical.FullCellId return IsExpectedCanonical(
? RuntimeEntityChange.Rebucketed canonical,
: RuntimeEntityChange.Updated, () => canonical.PositionAuthorityVersion == positionVersion
canonical); && 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( public bool CommitRebucket(
@ -641,12 +720,21 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
canonical, canonical,
fullCellId, fullCellId,
canonicalLandblockId); canonicalLandblockId);
acknowledgeProjection?.Invoke(canonical); ulong spatialVersion = canonical.SpatialAuthorityVersion;
if (!Entities.IsCurrent(canonical)) if (previous == fullCellId)
return false; {
if (previous != fullCellId) acknowledgeProjection?.Invoke(canonical);
PublishEntity(RuntimeEntityChange.Rebucketed, canonical); return IsExpectedCanonical(
return Entities.IsCurrent(canonical); canonical,
() => canonical.SpatialAuthorityVersion
== spatialVersion);
}
return AcknowledgeProjectionAndPublish(
canonical,
() => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Rebucketed,
() => canonical.SpatialAuthorityVersion == spatialVersion);
} }
public bool CommitWithdrawal( public bool CommitWithdrawal(
@ -660,11 +748,12 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.SuspendObjectClock(canonical); Entities.SuspendObjectClock(canonical);
Entities.SetFullCell(canonical, 0u, 0u); Entities.SetFullCell(canonical, 0u, 0u);
acknowledgeProjection?.Invoke(canonical); ulong spatialVersion = canonical.SpatialAuthorityVersion;
if (!Entities.IsCurrent(canonical)) return AcknowledgeProjectionAndPublish(
return false; canonical,
PublishEntity(RuntimeEntityChange.Withdrawn, canonical); () => acknowledgeProjection?.Invoke(canonical),
return Entities.IsCurrent(canonical); RuntimeEntityChange.Withdrawn,
() => canonical.SpatialAuthorityVersion == spatialVersion);
} }
public bool CommitChildNoDraw( public bool CommitChildNoDraw(
@ -678,11 +767,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
return false; return false;
Entities.SetChildNoDraw(canonical, noDraw); Entities.SetChildNoDraw(canonical, noDraw);
acknowledgeProjection?.Invoke(canonical); ulong physicsMutationVersion =
if (!Entities.IsCurrent(canonical)) canonical.PhysicsStateMutationVersion;
return false; return AcknowledgeProjectionAndPublish(
PublishEntity(RuntimeEntityChange.Updated, canonical); canonical,
return Entities.IsCurrent(canonical); () => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Updated,
() => canonical.PhysicsStateMutationVersion
== physicsMutationVersion);
} }
public bool RetireAfterProjectionAcquisitionFailure( public bool RetireAfterProjectionAcquisitionFailure(
@ -694,6 +786,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
return false; return false;
Entities.AdvanceLifetimeMutation(canonical.ServerGuid); Entities.AdvanceLifetimeMutation(canonical.ServerGuid);
Entities.RetainTeardown(canonical);
PublishEntity(RuntimeEntityChange.Deleted, canonical); PublishEntity(RuntimeEntityChange.Deleted, canonical);
return true; return true;
} }
@ -731,6 +824,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
&& Entities.RemoveActive(active)) && Entities.RemoveActive(active))
{ {
retiredCanonical = active; retiredCanonical = active;
Entities.RetainTeardown(active);
PublishEntity( PublishEntity(
removeRetainedObject removeRetainedObject
? RuntimeEntityChange.Deleted ? RuntimeEntityChange.Deleted
@ -805,6 +899,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{ {
if (!Entities.RemoveActive(canonical)) if (!Entities.RemoveActive(canonical))
continue; continue;
Entities.RetainTeardown(canonical);
PublishEntity(RuntimeEntityChange.Deleted, canonical); PublishEntity(RuntimeEntityChange.Deleted, canonical);
} }
return retired; return retired;
@ -827,8 +922,52 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
{ {
if (_disposed) if (_disposed)
return; 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<Exception>? 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( private bool CommitPositionChannelUpdate(
@ -847,11 +986,14 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Entities.RefreshSnapshot(canonical, accepted); Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvancePositionAuthority(canonical); Entities.AdvancePositionAuthority(canonical);
acknowledgeProjection?.Invoke(canonical); ulong positionVersion = canonical.PositionAuthorityVersion;
if (!Entities.IsCurrent(canonical)) ulong spatialVersion = canonical.SpatialAuthorityVersion;
return false; return AcknowledgeProjectionAndPublish(
PublishEntity(RuntimeEntityChange.Updated, canonical); canonical,
return Entities.IsCurrent(canonical); () => acknowledgeProjection?.Invoke(canonical),
RuntimeEntityChange.Updated,
() => canonical.PositionAuthorityVersion == positionVersion
&& canonical.SpatialAuthorityVersion == spatialVersion);
} }
private void PublishEntity( private void PublishEntity(
@ -859,6 +1001,37 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
RuntimeEntityRecord canonical) => RuntimeEntityRecord canonical) =>
Events.PublishEntity(change, canonical); Events.PublishEntity(change, canonical);
private bool AcknowledgeProjectionAndPublish(
RuntimeEntityRecord canonical,
Action acknowledgeProjection,
RuntimeEntityChange change,
Func<bool> matchesCommittedMutation)
{
try
{
acknowledgeProjection();
}
finally
{
if (IsExpectedCanonical(
canonical,
matchesCommittedMutation))
{
PublishEntity(change, canonical);
}
}
return IsExpectedCanonical(
canonical,
matchesCommittedMutation);
}
private bool IsExpectedCanonical(
RuntimeEntityRecord canonical,
Func<bool> matchesCommittedMutation) =>
Entities.IsCurrent(canonical)
&& matchesCommittedMutation();
private void EnsureNotDisposed() => private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);

View file

@ -50,6 +50,8 @@ public sealed class RuntimeEntityRecord
public uint CanonicalLandblockId { get; internal set; } public uint CanonicalLandblockId { get; internal set; }
public uint RawPhysicsState { get; internal set; } public uint RawPhysicsState { get; internal set; }
public PhysicsStateFlags FinalPhysicsState { get; internal set; } public PhysicsStateFlags FinalPhysicsState { get; internal set; }
public ulong SpatialAuthorityVersion { get; private set; }
public ulong PhysicsStateMutationVersion { get; private set; }
/// <summary> /// <summary>
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. /// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner.
@ -72,6 +74,8 @@ public sealed class RuntimeEntityRecord
public ulong VectorAuthorityVersion { get; private set; } public ulong VectorAuthorityVersion { get; private set; }
public ulong VelocityAuthorityVersion { get; private set; } public ulong VelocityAuthorityVersion { get; private set; }
public ulong MovementAuthorityVersion { 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 ObjDescAuthorityVersion { get; private set; }
public ulong CreateIntegrationVersion { get; private set; } = 1UL; public ulong CreateIntegrationVersion { get; private set; } = 1UL;
public bool DeleteAcceptedForTeardown { get; internal set; } public bool DeleteAcceptedForTeardown { get; internal set; }
@ -94,6 +98,7 @@ public sealed class RuntimeEntityRecord
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState)
{ {
StateAuthorityVersion++; StateAuthorityVersion++;
PhysicsStateMutationVersion++;
RawPhysicsState = rawState; RawPhysicsState = rawState;
RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply(
FinalPhysicsState, FinalPhysicsState,
@ -123,6 +128,10 @@ public sealed class RuntimeEntityRecord
VelocityAuthorityVersion++; VelocityAuthorityVersion++;
} }
internal void AdvanceMovementCommit() => MovementCommitVersion++;
internal void AdvanceParentCommit() => ParentCommitVersion++;
internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++; internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++;
internal void AdvanceCreateAuthority() internal void AdvanceCreateAuthority()
@ -138,6 +147,7 @@ public sealed class RuntimeEntityRecord
internal void SetChildNoDraw(bool noDraw) internal void SetChildNoDraw(bool noDraw)
{ {
PhysicsStateMutationVersion++;
FinalPhysicsState = noDraw FinalPhysicsState = noDraw
? FinalPhysicsState | PhysicsStateFlags.NoDraw ? FinalPhysicsState | PhysicsStateFlags.NoDraw
: FinalPhysicsState & ~PhysicsStateFlags.NoDraw; : FinalPhysicsState & ~PhysicsStateFlags.NoDraw;
@ -149,12 +159,22 @@ public sealed class RuntimeEntityRecord
{ {
if (refreshPosition && Snapshot.Position is { } position) if (refreshPosition && Snapshot.Position is { } position)
{ {
FullCellId = position.LandblockId; SetFullCell(
CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu; position.LandblockId,
(position.LandblockId & 0xFFFF0000u) | 0xFFFFu);
} }
RawPhysicsState = Snapshot.Physics?.RawState RawPhysicsState = Snapshot.Physics?.RawState
?? Snapshot.PhysicsState ?? Snapshot.PhysicsState
?? 0u; ?? 0u;
} }
internal void SetFullCell(
uint fullCellId,
uint canonicalLandblockId)
{
SpatialAuthorityVersion++;
FullCellId = fullCellId;
CanonicalLandblockId = canonicalLandblockId;
}
} }

View file

@ -195,6 +195,32 @@ public sealed class CurrentGameRuntimeAdapterTests
spawn.Guid, spawn.Guid,
Harness.PlayerGuid, Harness.PlayerGuid,
newSlot: 3); newSlot: 3);
var objDesc = new ObjDescEvent.Parsed(
spawn.Guid,
new CreateObject.ModelData(
0x04000001u,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
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( var vector = new VectorUpdate.Parsed(
spawn.Guid, spawn.Guid,
new Vector3(1f, 2f, 3f), new Vector3(1f, 2f, 3f),
@ -216,6 +242,12 @@ public sealed class CurrentGameRuntimeAdapterTests
acknowledgeProjection: null, acknowledgeProjection: null,
out _, out _,
out _)); out _));
Assert.True(direct.CommitChildNoDraw(
directCanonical,
noDraw: true));
Assert.True(direct.CommitChildNoDraw(
directCanonical,
noDraw: false));
WorldSession.EntityPositionUpdate position = WorldSession.EntityPositionUpdate position =
Position(spawn.Guid, spawn.InstanceSequence); Position(spawn.Guid, spawn.InstanceSequence);
Assert.True(direct.TryApplyPosition( Assert.True(direct.TryApplyPosition(
@ -228,6 +260,10 @@ public sealed class CurrentGameRuntimeAdapterTests
out _, out _,
out _, out _,
out _)); out _));
Assert.True(direct.CommitRebucket(
directCanonical,
0x12360001u,
0x1236FFFFu));
Assert.True(direct.TryApplyPickup( Assert.True(direct.TryApplyPickup(
new PickupEvent.Parsed( new PickupEvent.Parsed(
spawn.Guid, spawn.Guid,
@ -253,8 +289,22 @@ public sealed class CurrentGameRuntimeAdapterTests
spawn.Guid, spawn.Guid,
Harness.PlayerGuid, Harness.PlayerGuid,
newSlot: 3); 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.TryApplyVector(vector, out _));
Assert.True(graphical.Entities.TryApplyState(state, out _, 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( Assert.True(graphical.Entities.TryApplyPosition(
position, position,
isLocalPlayer: false, isLocalPlayer: false,
@ -263,6 +313,9 @@ public sealed class CurrentGameRuntimeAdapterTests
out _, out _,
out _, out _,
out _)); out _));
Assert.True(graphical.Entities.RebucketLiveEntity(
spawn.Guid,
0x12360001u));
Assert.True(graphical.Entities.TryApplyPickup( Assert.True(graphical.Entities.TryApplyPickup(
new PickupEvent.Parsed( new PickupEvent.Parsed(
spawn.Guid, spawn.Guid,

View file

@ -9,6 +9,7 @@ using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
@ -244,6 +245,7 @@ public sealed class LiveEntityLifecycleStressTests
fixture.Effects.ClearNetworkState(); fixture.Effects.ClearNetworkState();
Assert.Equal(0, fixture.Runtime.Count); Assert.Equal(0, fixture.Runtime.Count);
Assert.Equal(0, fixture.Effects.PendingPacketCount); Assert.Equal(0, fixture.Effects.PendingPacketCount);
AssertOwnershipLedgerConverged(fixture);
} }
[Fact] [Fact]
@ -299,6 +301,98 @@ public sealed class LiveEntityLifecycleStressTests
Assert.Equal(0, fixture.Spatial.PendingBucketCount); 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 sealed class Fixture : IDisposable
{ {
private readonly Dictionary<uint, DatPhysicsScript> _scripts = new(); private readonly Dictionary<uint, DatPhysicsScript> _scripts = new();
@ -328,7 +422,8 @@ public sealed class LiveEntityLifecycleStressTests
randomUnit: () => 0.5, randomUnit: () => 0.5,
canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true); canAdvanceOwner: ownerId => _effects?.CanAdvanceOwner(ownerId) ?? true);
Runtime = LiveEntityRuntimeFixture.Create( EntityObjects = new RuntimeEntityObjectLifetime();
Runtime = new LiveEntityRuntime(
Spatial, Spatial,
new DelegateLiveEntityResourceLifecycle( new DelegateLiveEntityResourceLifecycle(
entity => entity =>
@ -358,7 +453,8 @@ public sealed class LiveEntityLifecycleStressTests
cleanups.Add(() => _liveLights?.Forget(record)); cleanups.Add(() => _liveLights?.Forget(record));
} }
LiveEntityTeardown.Run(cleanups); LiveEntityTeardown.Run(cleanups);
}); },
EntityObjects);
Effects = new EntityEffectController( Effects = new EntityEffectController(
Runtime, Runtime,
@ -386,6 +482,7 @@ public sealed class LiveEntityLifecycleStressTests
internal LightManager Lights { get; } = new(); internal LightManager Lights { get; } = new();
internal LightingHookSink Lighting { get; } internal LightingHookSink Lighting { get; }
internal PhysicsScriptRunner Runner { get; } internal PhysicsScriptRunner Runner { get; }
internal RuntimeEntityObjectLifetime EntityObjects { get; }
internal LiveEntityRuntime Runtime { get; } internal LiveEntityRuntime Runtime { get; }
internal EntityEffectController Effects { get; } internal EntityEffectController Effects { get; }
internal ProjectileController Projectiles { get; } internal ProjectileController Projectiles { get; }

View file

@ -393,6 +393,124 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Equal(1, lifetime.Events.SubscriberCount); 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<RuntimeInventoryItemSnapshot>();
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] [Fact]
public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval() public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval()
{ {
@ -530,6 +648,97 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Equal(0, lifetime.Entities.PendingTeardownCount); 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<uint>();
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<RuntimeEntityRecord> 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] [Fact]
public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate() public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate()
{ {
@ -685,6 +894,274 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.False(lifetime.Entities.TryGetActive(guid, out _)); 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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( private static RuntimeEntityRecord Accept(
RuntimeEntityObjectLifetime lifetime, RuntimeEntityObjectLifetime lifetime,
WorldSession.EntitySpawn spawn) WorldSession.EntitySpawn spawn)