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 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<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> _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;

View file

@ -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(

View file

@ -16,8 +16,10 @@ public interface IRuntimeEntityObjectEventSource
/// <summary>
/// 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.
/// </summary>
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> _pendingDispatch = [];
private IRuntimeEntityObjectObserver[] _observers = [];
private Func<RuntimeGenerationToken> _generation = static () => default;
private Func<ulong> _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)

View file

@ -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);
/// <summary>
/// One exact DeleteObject acceptance issued by a
/// <see cref="RuntimeEntityObjectLifetime"/>. 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<RuntimeGenerationToken> generation,
Func<ulong> 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<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(
@ -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<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() =>
ObjectDisposedException.ThrowIf(_disposed, this);

View file

@ -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; }
/// <summary>
/// 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 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;
}
}

View file

@ -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<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(
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,

View file

@ -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<uint, DatPhysicsScript> _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; }

View file

@ -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<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]
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<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]
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<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(
RuntimeEntityObjectLifetime lifetime,
WorldSession.EntitySpawn spawn)