refactor(runtime): publish canonical entity object deltas

Issue stable Runtime identities at canonical registration, publish entity and inventory commits through one generation-stamped synchronous stream, and make graphical adapters borrow the same direct views and events as a no-window host. Preserve exact projection teardown and retail mutation order while removing App-side event reconstruction.

Make the hard-recenter ordering fixture independent of the production two-millisecond frame budget so its injected-failure gate is deterministic.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-26 06:42:13 +02:00
parent d3e96ff912
commit ce3ac310d9
23 changed files with 2352 additions and 666 deletions

View file

@ -891,7 +891,7 @@ internal sealed class SessionPlayerCompositionPhase
liveSessionCommands,
d.PlayerIdentity,
live.LiveEntities,
d.EntityObjects.Objects,
d.EntityObjects,
d.Chat,
d.PlayerController,
worldReveal,

View file

@ -1590,6 +1590,7 @@ public sealed class GameWindow :
_streamer,
_equippedChildRenderer,
_liveEntities,
_runtimeEntityObjects,
_renderSceneShadow,
_livePresentationBindings,
_entityEffectAdvance,

View file

@ -16,6 +16,7 @@ using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Audio;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions.Input;
using DatReaderWriter;
@ -81,6 +82,7 @@ internal sealed record LiveShutdownRoots(
LandblockStreamer? Streamer,
EquippedChildRenderController? EquippedChildren,
LiveEntityRuntime? LiveEntities,
RuntimeEntityObjectLifetime EntityObjects,
RenderSceneShadowRuntime? RenderSceneShadow,
LivePresentationRuntimeBindings? PresentationBindings,
DeferredEntityEffectAdvanceSource EffectAdvance,
@ -384,6 +386,12 @@ internal static class GameWindowShutdownManifest
"shadow render scene",
() => live.RenderSceneShadow?.Dispose()),
]),
new ResourceShutdownStage("runtime entity/object stream",
[
Hard(
"runtime entity/object lifetime",
live.EntityObjects.Dispose),
]),
new ResourceShutdownStage("effect dispatch edges",
[
Hard("live-presentation bindings", () => live.PresentationBindings?.Dispose()),

View file

@ -5,9 +5,9 @@ using AcDream.App.Net;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
@ -35,7 +35,7 @@ internal sealed class CurrentGameRuntimeAdapter
ICommandBus commands,
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
ClientObjectTable objects,
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
@ -49,17 +49,18 @@ internal sealed class CurrentGameRuntimeAdapter
session,
playerIdentity,
entities,
objects,
entityObjects,
chat,
playerController,
worldReveal,
clock);
entityObjects.BindEventContext(
() => _view.Generation,
() => clock.FrameNumber);
_events = new CurrentGameRuntimeEventAdapter(
_view,
entities,
objects,
chat,
clock);
entityObjects,
chat);
_commands = new CurrentGameRuntimeCommandAdapter(
session,
sessionHost,

View file

@ -1,8 +1,6 @@
using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
namespace AcDream.App.Runtime;
@ -26,31 +24,30 @@ internal interface ICurrentGameRuntimeEventSink
internal sealed class CurrentGameRuntimeEventAdapter
: IRuntimeEventSource,
ICurrentGameRuntimeEventSink,
IRuntimeEntityObjectObserver,
IDisposable
{
private readonly CurrentGameRuntimeViewAdapter _view;
private readonly LiveEntityRuntime _entities;
private readonly ClientObjectTable _objects;
private readonly RuntimeEntityObjectLifetime _entityObjects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock;
private readonly RuntimeEventSequencer _events = new();
private readonly object _observerGate = new();
private IRuntimeEventObserver[] _observers = [];
private IDisposable? _entityObjectSubscription;
private bool _ownerEventsAttached;
private bool _disposed;
internal long DispatchFailureCount { get; private set; }
internal Exception? LastDispatchFailure { get; private set; }
public CurrentGameRuntimeEventAdapter(
CurrentGameRuntimeViewAdapter view,
LiveEntityRuntime entities,
ClientObjectTable objects,
ChatLog chat,
IGameRuntimeClock clock)
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat)
{
_view = view ?? throw new ArgumentNullException(nameof(view));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_entityObjects = entityObjects
?? throw new ArgumentNullException(nameof(entityObjects));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public IDisposable Subscribe(IRuntimeEventObserver observer)
@ -106,7 +103,16 @@ internal sealed class CurrentGameRuntimeEventAdapter
primaryObjectId,
text);
foreach (IRuntimeEventObserver observer in observers)
observer.OnCommand(in delta);
{
try
{
observer.OnCommand(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
public void EmitLifecycle(RuntimeLifecycleState previous)
@ -123,7 +129,16 @@ internal sealed class CurrentGameRuntimeEventAdapter
previous,
current);
foreach (IRuntimeEventObserver observer in observers)
observer.OnLifecycle(in delta);
{
try
{
observer.OnLifecycle(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void Unsubscribe(IRuntimeEventObserver observer)
@ -159,12 +174,7 @@ internal sealed class CurrentGameRuntimeEventAdapter
private void AttachOwnerEvents()
{
_entities.ProjectionVisibilityChanged += OnEntityVisibilityChanged;
_objects.ObjectAdded += OnObjectAdded;
_objects.ObjectUpdated += OnObjectUpdated;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemovalClassified += OnObjectRemoved;
_objects.Cleared += OnObjectsCleared;
_entityObjectSubscription = _entityObjects.Events.Subscribe(this);
_chat.EntryAppended += OnChatEntry;
_ownerEventsAttached = true;
}
@ -174,79 +184,43 @@ internal sealed class CurrentGameRuntimeEventAdapter
if (!_ownerEventsAttached)
return;
_chat.EntryAppended -= OnChatEntry;
_objects.Cleared -= OnObjectsCleared;
_objects.ObjectRemovalClassified -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectUpdated -= OnObjectUpdated;
_objects.ObjectAdded -= OnObjectAdded;
_entities.ProjectionVisibilityChanged -= OnEntityVisibilityChanged;
_entityObjectSubscription?.Dispose();
_entityObjectSubscription = null;
_ownerEventsAttached = false;
}
private void OnEntityVisibilityChanged(LiveEntityRecord record, bool visible)
public void OnEntity(in RuntimeEntityDelta delta)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
RuntimeEntityChange change = visible
? RuntimeEntityChange.Updated
: (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0
? RuntimeEntityChange.Hidden
: RuntimeEntityChange.Withdrawn;
var delta = new RuntimeEntityDelta(
NextStamp(),
change,
CurrentGameRuntimeViewAdapter.Snapshot(record));
foreach (IRuntimeEventObserver observer in observers)
observer.OnEntity(in delta);
{
try
{
observer.OnEntity(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void OnObjectAdded(ClientObject item) =>
EmitInventory(RuntimeInventoryChange.Added, item);
private void OnObjectUpdated(ClientObject item) =>
EmitInventory(RuntimeInventoryChange.Updated, item);
private void OnObjectMoved(ClientObjectMove move)
{
ClientObject? item = move.Item ?? _objects.Get(move.ItemId);
if (item is not null)
EmitInventory(RuntimeInventoryChange.Moved, item);
}
private void OnObjectRemoved(ClientObjectRemoval removal) =>
EmitInventory(
RuntimeInventoryChange.Removed,
removal.Object,
removal.Generation);
private void OnObjectsCleared()
public void OnInventory(in RuntimeInventoryDelta delta)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeInventoryDelta(
NextStamp(),
RuntimeInventoryChange.Cleared,
default);
foreach (IRuntimeEventObserver observer in observers)
observer.OnInventory(in delta);
}
private void EmitInventory(
RuntimeInventoryChange change,
ClientObject item,
ushort? exactGeneration = null)
{
if (!TryObservers(out IRuntimeEventObserver[] observers))
return;
var delta = new RuntimeInventoryDelta(
NextStamp(),
change,
CurrentGameRuntimeViewAdapter.Snapshot(
item,
_entities,
exactGeneration));
foreach (IRuntimeEventObserver observer in observers)
observer.OnInventory(in delta);
{
try
{
observer.OnInventory(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void OnChatEntry(ChatEntry entry)
@ -263,11 +237,20 @@ internal sealed class CurrentGameRuntimeEventAdapter
entry.Text,
entry.ChannelName));
foreach (IRuntimeEventObserver observer in observers)
observer.OnChat(in delta);
{
try
{
observer.OnChat(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private RuntimeEventStamp NextStamp() =>
_events.Next(_view.Generation, _clock.FrameNumber);
_entityObjects.Events.NextStamp();
private bool TryObservers(out IRuntimeEventObserver[] observers)
{
@ -275,6 +258,12 @@ internal sealed class CurrentGameRuntimeEventAdapter
return observers.Length != 0;
}
private void RecordDispatchFailure(Exception error)
{
DispatchFailureCount++;
LastDispatchFailure = error;
}
private sealed class ObserverSubscription(
CurrentGameRuntimeEventAdapter owner,
IRuntimeEventObserver observer)

View file

@ -5,6 +5,7 @@ using AcDream.App.World;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
namespace AcDream.App.Runtime;
@ -21,8 +22,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly ClientObjectTable _objects;
private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock;
private readonly EntityView _entityView;
private readonly InventoryView _inventoryView;
private readonly IRuntimeEntityView _entityView;
private readonly IRuntimeInventoryView _inventoryView;
private readonly ChatView _chatView;
private readonly MovementView _movementView;
private readonly PortalView _portalView;
@ -32,7 +33,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
LiveSessionController session,
LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities,
ClientObjectTable objects,
RuntimeEntityObjectLifetime entityObjects,
ChatLog chat,
ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal,
@ -42,12 +43,13 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_playerIdentity = playerIdentity
?? throw new ArgumentNullException(nameof(playerIdentity));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
ArgumentNullException.ThrowIfNull(entityObjects);
_objects = entityObjects.Objects;
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = new EntityView(_entities);
_inventoryView = new InventoryView(_objects, _entities);
_entityView = entityObjects.EntityView;
_inventoryView = entityObjects.InventoryView;
_chatView = new ChatView(_chat);
_movementView = new MovementView(
playerController
@ -108,103 +110,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
internal void Deactivate() => _active = false;
internal static RuntimeEntitySnapshot Snapshot(LiveEntityRecord record) =>
new(
new RuntimeEntityIdentity(
record.ServerGuid,
record.LocalEntityId ?? 0u,
record.Generation),
record.FullCellId,
(uint)record.FinalPhysicsState,
record.PhysicsBody?.CellPosition,
record.LocalEntityId.HasValue,
record.IsSpatiallyVisible,
record.InitialHydrationCompleted);
internal static RuntimeInventoryItemSnapshot Snapshot(
ClientObject item,
LiveEntityRuntime entities,
ushort? exactGeneration = null)
{
ushort generation = exactGeneration
?? (entities.TryGetRecord(item.ObjectId, out LiveEntityRecord record)
? record.Generation
: (ushort)0);
return new RuntimeInventoryItemSnapshot(
item.ObjectId,
generation,
item.Name,
item.ContainerId,
item.ContainerSlot,
item.WielderId,
(uint)item.CurrentlyEquippedLocation,
item.StackSize,
item.Value);
}
private sealed class EntityView(LiveEntityRuntime owner)
: IRuntimeEntityView
{
public int Count => owner.Count;
public int MaterializedCount => owner.MaterializedCount;
public bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity)
{
if (owner.TryGetRecord(serverGuid, out LiveEntityRecord record))
{
entity = Snapshot(record);
return true;
}
entity = default;
return false;
}
public void Visit(IRuntimeEntityVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (LiveEntityRecord record in owner.Records)
{
RuntimeEntitySnapshot entity = Snapshot(record);
visitor.Visit(in entity);
}
}
}
private sealed class InventoryView(
ClientObjectTable owner,
LiveEntityRuntime entities)
: IRuntimeInventoryView
{
public int ObjectCount => owner.ObjectCount;
public int ContainerCount => owner.ContainerCount;
public bool TryGet(
uint objectId,
out RuntimeInventoryItemSnapshot item)
{
ClientObject? found = owner.Get(objectId);
if (found is not null)
{
item = Snapshot(found, entities);
return true;
}
item = default;
return false;
}
public void Visit(IRuntimeInventoryVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (ClientObject item in owner.Objects)
{
RuntimeInventoryItemSnapshot snapshot = Snapshot(item, entities);
visitor.Visit(in snapshot);
}
}
}
private sealed class ChatView(ChatLog owner) : IRuntimeChatView
{
public long Revision => owner.Revision;

View file

@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using System.Numerics;
using System.Runtime.ExceptionServices;
@ -377,29 +378,13 @@ public sealed class LiveEntityRecord
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) =>
_directory.ApplyRawPhysicsState(Canonical, rawState);
internal void AdvancePositionAuthority() =>
_directory.AdvancePositionAuthority(Canonical);
internal void AdvanceVectorAuthority() =>
_directory.AdvanceVectorAuthority(Canonical);
internal void AdvanceMovementAuthority() =>
_directory.AdvanceMovementAuthority(Canonical);
internal void AdvanceObjDescAuthority()
internal void NoteObjDescProjectionSynchronization()
{
_directory.AdvanceObjDescAuthority(Canonical);
AppearanceProjectionSynchronizationPending = true;
if (AppearanceHydrationInProgress)
AppearanceHydrationRetryRequested = true;
}
internal void AdvanceCreateAuthority() =>
_directory.AdvanceCreateAuthority(Canonical);
internal void SetChildNoDraw(bool noDraw) =>
_directory.SetChildNoDraw(Canonical, noDraw);
/// <summary>
/// Cell used when a streamed landblock asks live objects to restore their
/// spatial projection. Once materialized, the canonical runtime cell wins;
@ -582,150 +567,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
: "A live entity cannot register from inside atomic resource registration.");
}
InboundCreateResult result = _directory.AcceptCreate(incoming);
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration)
return new LiveEntityRegistrationResult(
result,
Canonical: null,
Projection: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
ulong sessionVersion = _directory.SessionLifetimeVersion;
ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid);
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
RuntimeEntityRegistrationResult registration =
_entityObjects.RegisterEntity(
incoming,
RetirePriorProjection);
RuntimeEntityRecord? canonical = registration.Canonical;
LiveEntityRecord? projection = canonical is null
? null
: _projections.GetCurrentOrDefault(canonical.ServerGuid);
if (projection is not null
&& ReferenceEquals(projection.Canonical, canonical))
{
if (_directory.TryGetActive(
incoming.Guid,
out RuntimeEntityRecord retainedCanonical))
{
_directory.RefreshSnapshot(
retainedCanonical,
result.Snapshot,
refreshPosition: true);
_directory.AdvanceCreateAuthority(retainedCanonical);
_projections.TryGet(
retainedCanonical,
out LiveEntityRecord? retainedProjection);
if (retainedProjection is not null)
RefreshSpatialRuntimeIndexes(retainedProjection);
return new LiveEntityRegistrationResult(
result,
retainedCanonical,
retainedProjection,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
}
// A failed materialization rollback retains this exact incarnation
// as a teardown tombstone. Do not create a second active record
// with the same (GUID, INSTANCE_TS) while its partial resources are
// still converging; a later retransmit can recover after teardown.
if (_directory.TryGetTeardown(
incoming.Guid,
result.Snapshot.InstanceSequence,
out _))
{
return new LiveEntityRegistrationResult(
result,
Canonical: null,
Projection: null,
false,
false);
}
// Defensive repair for a caller that cleared only the logical map.
// Normal session teardown clears both owners together.
RuntimeEntityRecord recoveredCanonical =
_directory.AddActive(result.Snapshot);
return new LiveEntityRegistrationResult(
result,
recoveredCanonical,
Projection: null,
LogicalRegistrationCreated: true,
ReplacedExistingGeneration: false);
RefreshSpatialRuntimeIndexes(projection);
}
bool replaced = _directory.RemoveActive(
incoming.Guid,
out RuntimeEntityRecord? oldCanonical);
LiveEntityRecord? old = null;
if (oldCanonical is not null
&& _projections.TryGet(oldCanonical, out LiveEntityRecord? projected))
{
old = projected;
if (!_projections.RemoveActive(old))
{
throw new InvalidOperationException(
$"Exact App projection for 0x{incoming.Guid:X8}/{old.Generation} could not be retired.");
}
}
if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration)
ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence);
Exception? tearDownFailure = null;
if (old is not null)
{
RetainTeardownRecord(old);
_logicalTeardownDepth++;
try
{
try
{
TearDownRecord(old);
ReleaseTeardownRecord(old);
}
catch (Exception error)
{
tearDownFailure = error;
}
}
finally
{
_logicalTeardownDepth--;
}
}
else if (oldCanonical is not null)
{
TearDownCanonicalOnly(oldCanonical);
}
// Resource callbacks are arbitrary App integration code. Any nested
// create, accepted delete, or session reset advances this epoch. The
// outer packet is then superseded even when the nested action left no
// record/snapshot (delete/reset); reinstalling it would resurrect an
// incarnation after an accepted terminal event.
if (_directory.SessionLifetimeVersion != sessionVersion
|| CurrentLifetimeMutation(incoming.Guid) != operationVersion)
{
if (tearDownFailure is not null)
{
throw new AggregateException(
$"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its incoming replacement was superseded.",
tearDownFailure);
}
return new LiveEntityRegistrationResult(
SupersededCreateResult(),
_directory.TryGetActive(
incoming.Guid,
out RuntimeEntityRecord currentCanonical)
? currentCanonical
: null,
_projections.GetCurrentOrDefault(incoming.Guid),
false,
replaced,
tearDownFailure);
}
RuntimeEntityRecord canonical = _directory.AddActive(result.Snapshot);
return new LiveEntityRegistrationResult(
result,
registration.Inbound,
canonical,
Projection: null,
true,
replaced,
tearDownFailure);
projection,
registration.LogicalRegistrationCreated,
registration.ReplacedExistingGeneration,
registration.PriorGenerationCleanupFailure);
}
/// <summary>
@ -785,7 +646,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool createdSidecar = false;
if (!_projections.TryGet(expectedCanonical, out record))
{
uint localId = _directory.ClaimLocalId(expectedCanonical);
_ = expectedCanonical.LocalEntityId
?? throw new InvalidOperationException(
"Runtime must issue entity identity before App projection.");
try
{
record = _projections.AddMaterializing(expectedCanonical);
@ -800,7 +663,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
record.ProjectionKey = null;
record = null;
}
_directory.ReleaseLocalId(expectedCanonical);
throw;
}
}
@ -833,7 +695,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
_projections.RemoveActive(record);
record.ProjectionKey = null;
_directory.ReleaseLocalId(expectedCanonical);
record = null;
}
throw;
@ -870,14 +731,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
_projections.RemoveActive(record);
record.ProjectionKey = null;
_directory.ReleaseLocalId(expectedCanonical);
record.WorldEntity = null;
record = null;
throw;
}
if (_directory.IsCurrent(expectedCanonical))
_directory.RemoveActive(expectedCanonical);
_entityObjects.RetireAfterProjectionAcquisitionFailure(
expectedCanonical);
if (_projections.RemoveActive(record))
{
RetainTeardownRecord(record);
@ -983,11 +843,24 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool visible = _spatial.IsLiveEntityProjectionResident(key);
record.IsSpatiallyVisible = visible;
RefreshPresentation(record);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
record.FullCellId = spatialCellOrLandblockId;
record.CanonicalLandblockId = spatialCellOrLandblockId == 0
uint committedFullCell =
(spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu
? spatialCellOrLandblockId
: record.FullCellId;
uint committedLandblock = spatialCellOrLandblockId == 0
? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_entityObjects.CommitRebucket(
record.Canonical,
committedFullCell,
committedLandblock))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected
&& record.IsSpatiallyVisible
@ -1182,7 +1055,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false;
}
ClearWorldCell(serverGuid);
if (!_entityObjects.CommitWithdrawal(
record.Canonical,
AcknowledgeCelllessCanonicalCommit))
{
return false;
}
record.WorldEntity.ParentCellId = 0u;
return WithdrawLiveEntityProjection(serverGuid);
}
@ -1218,10 +1096,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_entityObjects.TryAcceptDelete(
delete,
isLocalPlayer,
removeRetainedObject,
out acceptedDelete))
return false;
AdvanceLifetimeMutation(delete.Guid);
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
// End active gameplay identity before arbitrary callbacks so a
// newer generation can be accepted re-entrantly, but retain the
@ -1229,11 +1106,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
// succeeded. A throwing resource callback can then be retried by
// the same accepted Delete instead of orphaning its owner.
LiveEntityRecord? retainedRegistrationFailure = record;
if (_directory.TryGetActive(
delete.Guid,
out RuntimeEntityRecord activeCanonical)
&& activeCanonical.Incarnation == delete.InstanceSequence
&& _directory.RemoveActive(activeCanonical))
if (acceptedDelete.RetiredCanonical is { } activeCanonical)
{
if (_projections.TryGet(
activeCanonical,
@ -1266,16 +1139,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
if (!retryingAcceptedDelete)
{
if (removeRetainedObject)
try
{
try
{
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
}
catch (Exception error)
{
(failures ??= new List<Exception>()).Add(error);
}
try
@ -1872,95 +1742,44 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_directory.TryGetSnapshot(guid, out spawn);
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _directory.TryApplyObjDesc(update, out accepted);
if (applied
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvanceObjDescAuthority();
else
_directory.AdvanceObjDescAuthority(canonical);
}
return applied;
}
=> _entityObjects.TryApplyObjDesc(
update,
canonical =>
{
if (_projections.TryGet(
canonical,
out LiveEntityRecord? record))
{
record.NoteObjDescProjectionSynchronization();
}
},
out accepted);
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _directory.TryApplyPickup(update, out accepted);
if (applied
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
else
_directory.AdvancePositionAuthority(canonical);
ClearWorldCell(canonical);
ParentAttachments.EndChildProjection(update.Guid);
}
return applied;
}
=> _entityObjects.TryApplyPickup(
update,
AcknowledgeCelllessCanonicalCommit,
out accepted);
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
{
bool applied = _directory.TryApplyCreateParent(update, out accepted);
if (applied
&& _directory.TryGetActive(
update.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
else
_directory.AdvancePositionAuthority(canonical);
}
return applied;
}
=> _entityObjects.TryApplyCreateParent(
update,
acknowledgeProjection: null,
out accepted);
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _directory.TryApplyParent(update, out accepted);
if (applied
&& _directory.TryGetActive(
update.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
else
_directory.AdvancePositionAuthority(canonical);
}
return applied;
}
=> _entityObjects.TryApplyParent(
update,
acknowledgeProjection: null,
out accepted);
internal bool CommitStagedParent(
ParentAttachmentRelation relation,
out WorldSession.EntitySpawn accepted)
{
bool committed = _directory.TryCommitParent(
relation.ChildGuid,
relation.ParentGuid,
relation.ParentLocation,
relation.PlacementId,
relation.ChildPositionSequence,
=> _entityObjects.TryCommitParent(
relation,
acknowledgeProjection: null,
out accepted);
if (committed
&& _directory.TryGetActive(
relation.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
}
return committed;
}
/// <summary>
/// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only
@ -1973,25 +1792,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ulong positionAuthorityVersion)
{
ArgumentNullException.ThrowIfNull(record);
if (!IsCurrentPositionAuthority(record, positionAuthorityVersion))
return false;
ClearWorldCell(record.ServerGuid);
return IsCurrentPositionAuthority(record, positionAuthorityVersion);
return _entityObjects.CommitAcceptedParentCellless(
record.Canonical,
positionAuthorityVersion,
AcknowledgeCelllessCanonicalCommit);
}
internal bool CommitAcceptedParentCellless(
RuntimeEntityRecord canonical,
ulong positionAuthorityVersion)
{
ArgumentNullException.ThrowIfNull(canonical);
if (!_directory.IsCurrent(canonical)
|| canonical.PositionAuthorityVersion != positionAuthorityVersion)
{
return false;
}
ClearWorldCell(canonical);
return _directory.IsCurrent(canonical)
&& canonical.PositionAuthorityVersion == positionAuthorityVersion;
return _entityObjects.CommitAcceptedParentCellless(
canonical,
positionAuthorityVersion,
AcknowledgeCelllessCanonicalCommit);
}
public bool TryApplyMotion(
@ -1999,43 +1813,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool retainPayload,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps);
if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, snapshot);
}
if (applied
&& retainPayload
&& _directory.TryGetActive(update.Guid, out canonical))
{
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvanceMovementAuthority();
else
_directory.AdvanceMovementAuthority(canonical);
}
return applied;
}
=> _entityObjects.TryApplyMotion(
update,
retainPayload,
acknowledgeProjection: null,
out accepted,
out timestamps);
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
{
bool applied = _directory.TryApplyVector(update, out accepted);
if (applied
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvanceVectorAuthority();
else
_directory.AdvanceVectorAuthority(canonical);
}
return applied;
}
=> _entityObjects.TryApplyVector(
update,
acknowledgeProjection: null,
out accepted);
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
=> TryApplyState(update, out accepted, out _);
@ -2044,23 +1833,19 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
SetState.Parsed update,
out WorldSession.EntitySpawn accepted,
out RetailPhysicsStateTransition transition)
{
bool applied = _directory.TryApplyState(update, out accepted);
transition = default;
if (applied
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
transition = _directory.ApplyRawPhysicsState(
canonical,
update.PhysicsState);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
RefreshPresentation(record);
}
return applied;
}
=> _entityObjects.TryApplyState(
update,
(canonical, _) =>
{
if (_projections.TryGet(
canonical,
out LiveEntityRecord? record))
{
RefreshPresentation(record);
}
},
out accepted,
out transition);
/// <summary>
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
@ -2070,9 +1855,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{
if (!_projections.TryGetCurrent(childServerGuid, out LiveEntityRecord? record))
return false;
record.SetChildNoDraw(noDraw);
RefreshPresentation(record);
return true;
return _entityObjects.CommitChildNoDraw(
record.Canonical,
noDraw,
_ => RefreshPresentation(record));
}
public bool TryApplyPosition(
@ -2084,62 +1870,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
bool hadCanonical = _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord? beforeCanonical);
LiveEntityRecord? beforePosition = null;
if (hadCanonical)
_projections.TryGet(beforeCanonical!, out beforePosition);
bool wasCellLess = hadCanonical
&& (beforeCanonical!.FullCellId == 0
|| beforePosition is null
|| !beforePosition.IsSpatiallyProjected
|| !beforePosition.IsSpatiallyVisible);
bool known = _directory.TryApplyPosition(
bool projectionRequiresTeleportHook =
_directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical)
&& (canonical.FullCellId == 0u
|| !_projections.TryGet(
canonical,
out LiveEntityRecord? projection)
|| !projection.IsSpatiallyProjected
|| !projection.IsSpatiallyVisible);
return _entityObjects.TryApplyPosition(
update,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
projectionRequiresTeleportHook,
acknowledgeProjection: null,
out disposition,
out accepted,
out timestamps);
if (known && _directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot))
{
bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected;
if (disposition is PositionTimestampDisposition.Apply)
{
timestamps = timestamps with
{
TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess,
};
}
if (_directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord acceptedCanonical))
{
_directory.RefreshSnapshot(
acceptedCanonical,
snapshot,
refreshPosition: acceptedPosition);
if (acceptedPosition
&& ReferenceEquals(acceptedCanonical, beforeCanonical))
{
if (_projections.TryGet(
acceptedCanonical,
out LiveEntityRecord? acceptedRecord)
&& ReferenceEquals(acceptedRecord, beforePosition))
{
acceptedRecord.AdvancePositionAuthority();
}
else
{
_directory.AdvancePositionAuthority(acceptedCanonical);
}
ParentAttachments.EndChildProjection(update.Guid);
}
}
}
return known;
}
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
@ -2669,15 +2419,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_isClearing = true;
_sessionClearPendingFinalization = true;
_directory.BeginSessionClear();
IReadOnlyList<RuntimeEntityRecord> retiredCanonicals =
_entityObjects.BeginSessionClear();
List<Exception>? failures = null;
try
{
foreach (RuntimeEntityRecord canonical in
_directory.ActiveRecords.ToArray())
foreach (RuntimeEntityRecord canonical in retiredCanonicals)
{
if (!_directory.RemoveActive(canonical))
continue;
if (_projections.TryGet(
canonical,
out LiveEntityRecord? record))
@ -2796,12 +2544,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_directory.RefreshSnapshot(canonical, accepted, refreshPosition);
}
private static InboundCreateResult SupersededCreateResult() => new(
CreateObjectTimestampDisposition.StaleGeneration,
default,
null,
default);
private ulong AdvanceLifetimeMutation(uint serverGuid)
=> _directory.AdvanceLifetimeMutation(serverGuid);
@ -2841,37 +2583,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ExceptionDispatchInfo.Capture(failure).Throw();
}
private void ClearWorldCell(uint guid)
private void AcknowledgeCelllessCanonicalCommit(
RuntimeEntityRecord canonical)
{
if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical))
if (!_directory.IsCurrent(canonical)
|| !_projections.TryGet(
canonical,
out LiveEntityRecord? record))
{
return;
ClearWorldCell(canonical);
}
}
private void ClearWorldCell(RuntimeEntityRecord canonical)
{
if (!_directory.IsCurrent(canonical))
return;
if (!_projections.TryGet(canonical, out LiveEntityRecord? record))
{
_directory.SetFullCell(canonical, 0, 0);
return;
}
bool wasOrdinaryRoot = record.ProjectionKey is { } key
&& _spatialRootObjects.TryGetValue(
key,
out LiveEntityRecord? indexedRoot)
&& ReferenceEquals(indexedRoot, record);
// Pickup/Parent is retail's leave-world edge. Suspend the canonical
// object clock while the record is still indexed as a root; clearing
// FullCellId first removes that evidence and makes the later projection
// withdrawal look like a duplicate non-root edge.
if (wasOrdinaryRoot)
{
record.SuspendObjectClock();
SynchronizePhysicsBodyActiveState(record);
}
_directory.SetFullCell(canonical, 0, 0);
SynchronizePhysicsBodyActiveState(record);
RefreshSpatialRuntimeIndexes(record);
}
@ -3091,27 +2814,50 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_projections.RetainTeardown(record);
}
private void TearDownCanonicalOnly(RuntimeEntityRecord canonical)
private Exception? RetirePriorProjection(
RuntimeEntityRecord canonical)
{
// A canonical-only incarnation has never crossed the App projection
// acquisition edge, so it owns no renderer/effect/animation suffix.
// Retain it briefly only so Runtime's validated mutators can converge
// any presentation-free physics state before the tombstone vanishes.
_directory.RetainTeardown(canonical);
if (!_projections.TryGet(
canonical,
out LiveEntityRecord? projection))
{
return _entityObjects.RetireCanonicalOnly(canonical);
}
if (!_projections.RemoveActive(projection))
{
return new InvalidOperationException(
$"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired.");
}
RetainTeardownRecord(projection);
_logicalTeardownDepth++;
try
{
_directory.SetPhysicsHost(canonical, null);
_directory.SetPhysicsBody(canonical, null);
_directory.SetPhysicsBodyAcquisitionInProgress(canonical, false);
_directory.SetHasPartArray(canonical, false);
_directory.ReleaseLocalId(canonical);
try
{
TearDownRecord(projection);
ReleaseTeardownRecord(projection);
return null;
}
catch (Exception error)
{
return error;
}
}
finally
{
_directory.ReleaseTeardown(canonical);
_logicalTeardownDepth--;
}
}
private void TearDownCanonicalOnly(RuntimeEntityRecord canonical)
{
Exception? failure = _entityObjects.RetireCanonicalOnly(canonical);
if (failure is not null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
private void ReleaseTeardownRecord(LiveEntityRecord record)
{
_projections.ReleaseTeardown(record);
@ -3125,7 +2871,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private void CompleteSessionClearIfConverged()
{
if (!_sessionClearPendingFinalization
|| !_directory.CompleteSessionClearIfConverged())
|| !_entityObjects.CompleteSessionClearIfConverged())
{
return;
}

View file

@ -80,7 +80,16 @@ public sealed class RuntimeEntityDirectory
var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record);
return record;
try
{
ClaimLocalId(record);
return record;
}
catch
{
_activeByGuid.Remove(snapshot.Guid);
throw;
}
}
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>

View file

@ -0,0 +1,271 @@
using AcDream.Core.Items;
namespace AcDream.Runtime.Entities;
public interface IRuntimeEntityObjectObserver
{
void OnEntity(in RuntimeEntityDelta delta);
void OnInventory(in RuntimeInventoryDelta delta);
}
public interface IRuntimeEntityObjectEventSource
{
IDisposable Subscribe(IRuntimeEntityObjectObserver observer);
}
/// <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.
/// </summary>
public sealed class RuntimeEntityObjectEventStream
: IRuntimeEntityObjectEventSource,
IDisposable
{
private readonly RuntimeEntityDirectory _entities;
private readonly ClientObjectTable _objects;
private readonly RuntimeEventSequencer _sequencer = new();
private readonly object _observerGate = new();
private IRuntimeEntityObjectObserver[] _observers = [];
private Func<RuntimeGenerationToken> _generation = static () => default;
private Func<ulong> _frameNumber = static () => 0UL;
private bool _contextBound;
private bool _disposed;
internal RuntimeEntityObjectEventStream(
RuntimeEntityDirectory entities,
ClientObjectTable objects)
{
_entities = entities
?? throw new ArgumentNullException(nameof(entities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_objects.ObjectAdded += OnObjectAdded;
_objects.ObjectUpdated += OnObjectUpdated;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemovalClassified += OnObjectRemoved;
_objects.Cleared += OnObjectsCleared;
}
public ulong LastSequence => _sequencer.LastSequence;
public int SubscriberCount => Volatile.Read(ref _observers).Length;
public long DispatchFailureCount { get; private set; }
public Exception? LastDispatchFailure { get; private set; }
/// <summary>
/// Binds the session-generation and Runtime-frame sources exactly once.
/// Both are queried at commit time so reconnect/reset cannot stamp an event
/// with a cached generation.
/// </summary>
public void BindContext(
Func<RuntimeGenerationToken> generation,
Func<ulong> frameNumber)
{
ArgumentNullException.ThrowIfNull(generation);
ArgumentNullException.ThrowIfNull(frameNumber);
lock (_observerGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_contextBound)
{
throw new InvalidOperationException(
"The Runtime entity/object event context is already bound.");
}
_generation = generation;
_frameNumber = frameNumber;
_contextBound = true;
}
}
public IDisposable Subscribe(IRuntimeEntityObjectObserver observer)
{
ArgumentNullException.ThrowIfNull(observer);
lock (_observerGate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
IRuntimeEntityObjectObserver[] current = _observers;
if (Array.IndexOf(current, observer) >= 0)
{
throw new InvalidOperationException(
"The Runtime entity/object observer is already subscribed.");
}
var replacement =
new IRuntimeEntityObjectObserver[current.Length + 1];
Array.Copy(current, replacement, current.Length);
replacement[^1] = observer;
Volatile.Write(ref _observers, replacement);
}
return new ObserverSubscription(this, observer);
}
/// <summary>
/// Allocates the next stamp for another domain on the same Runtime event
/// surface. J4J6 will move those remaining domain publishers into
/// Runtime; until then the App adapter borrows this sequencer.
/// </summary>
public RuntimeEventStamp NextStamp()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposed),
this);
return _sequencer.Next(_generation(), _frameNumber());
}
internal void PublishEntity(
RuntimeEntityChange change,
RuntimeEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
var delta = new RuntimeEntityDelta(
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);
}
}
}
public void Dispose()
{
lock (_observerGate)
{
if (_disposed)
return;
_disposed = true;
Volatile.Write(ref _observers, []);
_objects.Cleared -= OnObjectsCleared;
_objects.ObjectRemovalClassified -= OnObjectRemoved;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectUpdated -= OnObjectUpdated;
_objects.ObjectAdded -= OnObjectAdded;
}
}
private void OnObjectAdded(ClientObject item) =>
PublishInventory(RuntimeInventoryChange.Added, item);
private void OnObjectUpdated(ClientObject item) =>
PublishInventory(RuntimeInventoryChange.Updated, item);
private void OnObjectMoved(ClientObjectMove move)
{
ClientObject? item = move.Item ?? _objects.Get(move.ItemId);
RuntimeInventoryItemSnapshot snapshot = item is null
? new RuntimeInventoryItemSnapshot(
move.ItemId,
0,
string.Empty,
move.Current.ContainerId,
move.Current.ContainerSlot,
move.Current.WielderId,
(uint)move.Current.EquipLocation,
0,
0)
: RuntimeEntityObjectViews.Snapshot(item, _entities);
PublishInventory(RuntimeInventoryChange.Moved, snapshot);
}
private void OnObjectRemoved(ClientObjectRemoval removal) =>
PublishInventory(
RuntimeInventoryChange.Removed,
RuntimeEntityObjectViews.Snapshot(
removal.Object,
_entities,
removal.Generation));
private void OnObjectsCleared() =>
PublishInventory(
RuntimeInventoryChange.Cleared,
default(RuntimeInventoryItemSnapshot));
private void PublishInventory(
RuntimeInventoryChange change,
ClientObject item) =>
PublishInventory(
change,
RuntimeEntityObjectViews.Snapshot(item, _entities));
private void PublishInventory(
RuntimeInventoryChange change,
RuntimeInventoryItemSnapshot item)
{
var delta = new RuntimeInventoryDelta(
NextStamp(),
change,
item);
IRuntimeEntityObjectObserver[] observers =
Volatile.Read(ref _observers);
foreach (IRuntimeEntityObjectObserver observer in observers)
{
try
{
observer.OnInventory(in delta);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void RecordDispatchFailure(Exception error)
{
DispatchFailureCount++;
LastDispatchFailure = error;
}
private void Unsubscribe(IRuntimeEntityObjectObserver observer)
{
lock (_observerGate)
{
IRuntimeEntityObjectObserver[] current = _observers;
int index = Array.IndexOf(current, observer);
if (index < 0)
return;
if (current.Length == 1)
{
Volatile.Write(ref _observers, []);
return;
}
var replacement =
new IRuntimeEntityObjectObserver[current.Length - 1];
if (index != 0)
Array.Copy(current, 0, replacement, 0, index);
if (index != current.Length - 1)
{
Array.Copy(
current,
index + 1,
replacement,
index,
current.Length - index - 1);
}
Volatile.Write(ref _observers, replacement);
}
}
private sealed class ObserverSubscription(
RuntimeEntityObjectEventStream owner,
IRuntimeEntityObjectObserver observer)
: IDisposable
{
private RuntimeEntityObjectEventStream? _owner = owner;
public void Dispose() =>
Interlocked.Exchange(ref _owner, null)?.Unsubscribe(observer);
}
}

View file

@ -1,9 +1,17 @@
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Entities;
public readonly record struct RuntimeEntityRegistrationResult(
InboundCreateResult Inbound,
RuntimeEntityRecord? Canonical,
bool LogicalRegistrationCreated,
bool ReplacedExistingGeneration,
Exception? PriorGenerationCleanupFailure = null);
/// <summary>
/// One exact DeleteObject acceptance issued by a
/// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the
@ -14,15 +22,21 @@ public sealed class RuntimeEntityDeleteAcceptance
{
internal RuntimeEntityDeleteAcceptance(
RuntimeEntityObjectLifetime owner,
DeleteObject.Parsed delete)
DeleteObject.Parsed delete,
RuntimeEntityRecord? retiredCanonical,
bool removeRetainedObject)
{
Owner = owner;
Delete = delete;
RetiredCanonical = retiredCanonical;
RemoveRetainedObject = removeRetainedObject;
}
internal RuntimeEntityObjectLifetime Owner { get; }
internal bool Completed { get; set; }
public DeleteObject.Parsed Delete { get; }
public RuntimeEntityRecord? RetiredCanonical { get; }
public bool RemoveRetainedObject { get; }
}
/// <summary>
@ -30,17 +44,253 @@ public sealed class RuntimeEntityDeleteAcceptance
/// retained object lifetimes. Graphical and direct hosts borrow these exact
/// instances; they never allocate a second directory or object table.
/// </summary>
public sealed class RuntimeEntityObjectLifetime
public sealed class RuntimeEntityObjectLifetime : IDisposable
{
private bool _sessionClearInProgress;
private bool _disposed;
public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{
Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Objects = new ClientObjectTable();
var views = new RuntimeEntityObjectViews(Entities, Objects);
EntityView = views.Entities;
InventoryView = views.Inventory;
Events = new RuntimeEntityObjectEventStream(Entities, Objects);
}
public RuntimeEntityDirectory Entities { get; }
public ClientObjectTable Objects { get; }
public IRuntimeEntityView EntityView { get; }
public IRuntimeInventoryView InventoryView { get; }
public RuntimeEntityObjectEventStream Events { get; }
public void BindEventContext(
Func<RuntimeGenerationToken> generation,
Func<ulong> frameNumber)
{
EnsureNotDisposed();
Events.BindContext(generation, frameNumber);
}
/// <summary>
/// Owns the presentation-free half of retail's CreateObject lifetime
/// transaction. An attached graphical host may synchronously retire the
/// displaced projection through <paramref name="retirePriorProjection"/>;
/// a direct host omits it and Runtime retires canonical-only state.
/// </summary>
public RuntimeEntityRegistrationResult RegisterEntity(
WorldSession.EntitySpawn incoming,
Func<RuntimeEntityRecord, Exception?>? retirePriorProjection = null)
{
EnsureNotDisposed();
if (_sessionClearInProgress)
{
throw new InvalidOperationException(
"A Runtime entity cannot register while its session lifetime is clearing.");
}
InboundCreateResult result = Entities.AcceptCreate(incoming);
if (result.Disposition
is CreateObjectTimestampDisposition.StaleGeneration)
{
return new RuntimeEntityRegistrationResult(
result,
Canonical: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
}
ulong sessionVersion = Entities.SessionLifetimeVersion;
ulong operationVersion =
Entities.AdvanceLifetimeMutation(incoming.Guid);
if (result.Disposition
is CreateObjectTimestampDisposition.ExistingGeneration)
{
if (Entities.TryGetActive(
incoming.Guid,
out RuntimeEntityRecord retained))
{
Entities.RefreshSnapshot(
retained,
result.Snapshot,
refreshPosition: true);
Entities.AdvanceCreateAuthority(retained);
PublishEntity(RuntimeEntityChange.Updated, retained);
if (!IsCurrentOperation(
incoming.Guid,
retained,
sessionVersion,
operationVersion))
{
return SupersededRegistration(
incoming.Guid,
replacedExistingGeneration: false);
}
return new RuntimeEntityRegistrationResult(
result,
retained,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
}
if (Entities.TryGetTeardown(
incoming.Guid,
result.Snapshot.InstanceSequence,
out _))
{
return new RuntimeEntityRegistrationResult(
result,
Canonical: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
}
RuntimeEntityRecord recovered = Entities.AddActive(result.Snapshot);
PublishEntity(RuntimeEntityChange.Registered, recovered);
if (!IsCurrentOperation(
incoming.Guid,
recovered,
sessionVersion,
operationVersion))
{
return SupersededRegistration(
incoming.Guid,
replacedExistingGeneration: false);
}
return new RuntimeEntityRegistrationResult(
result,
recovered,
LogicalRegistrationCreated: true,
ReplacedExistingGeneration: false);
}
bool replaced = Entities.RemoveActive(
incoming.Guid,
out RuntimeEntityRecord? prior);
if (result.Disposition
is CreateObjectTimestampDisposition.NewGeneration)
{
Entities.ParentAttachments.EndGeneration(
incoming.Guid,
result.Snapshot.InstanceSequence);
}
Exception? cleanupFailure = null;
if (prior is not null)
{
try
{
PublishEntity(RuntimeEntityChange.Deleted, prior);
}
catch (Exception error)
{
cleanupFailure = error;
}
Exception? projectionFailure = retirePriorProjection is null
? RetireCanonicalOnly(prior)
: retirePriorProjection(prior);
cleanupFailure = Combine(cleanupFailure, projectionFailure);
}
if (Entities.SessionLifetimeVersion != sessionVersion
|| Entities.CurrentLifetimeMutation(incoming.Guid)
!= operationVersion)
{
if (cleanupFailure is not null)
{
throw new AggregateException(
$"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its incoming replacement was superseded.",
cleanupFailure);
}
return new RuntimeEntityRegistrationResult(
SupersededCreateResult(),
Entities.TryGetActive(
incoming.Guid,
out RuntimeEntityRecord current)
? current
: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: replaced);
}
RuntimeEntityRecord canonical = Entities.AddActive(result.Snapshot);
try
{
PublishEntity(RuntimeEntityChange.Registered, canonical);
}
catch (Exception error)
{
if (cleanupFailure is not null)
{
throw new AggregateException(
$"Live entity 0x{incoming.Guid:X8} registered after prior cleanup and commit observers failed.",
cleanupFailure,
error);
}
throw;
}
if (!IsCurrentOperation(
incoming.Guid,
canonical,
sessionVersion,
operationVersion))
{
if (cleanupFailure is not null)
{
throw new AggregateException(
$"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its committed replacement was superseded.",
cleanupFailure);
}
return SupersededRegistration(
incoming.Guid,
replaced);
}
return new RuntimeEntityRegistrationResult(
result,
canonical,
LogicalRegistrationCreated: true,
ReplacedExistingGeneration: replaced,
cleanupFailure);
}
/// <summary>
/// Completes a displaced incarnation that never crossed the graphical
/// projection acquisition edge.
/// </summary>
public Exception? RetireCanonicalOnly(RuntimeEntityRecord canonical)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
try
{
Entities.RetainTeardown(canonical);
try
{
Entities.SetPhysicsHost(canonical, null);
Entities.SetPhysicsBody(canonical, null);
Entities.SetPhysicsBodyAcquisitionInProgress(canonical, false);
Entities.SetHasPartArray(canonical, false);
Entities.ReleaseLocalId(canonical);
}
finally
{
Entities.ReleaseTeardown(canonical);
}
return null;
}
catch (Exception error)
{
return error;
}
}
/// <summary>
/// Applies the retained-object half of an accepted CreateObject while the
@ -54,6 +304,7 @@ public sealed class RuntimeEntityObjectLifetime
WorldSession.EntitySpawn spawn,
bool replaceGeneration)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (canonical.ServerGuid != spawn.Guid
|| canonical.Incarnation != spawn.InstanceSequence)
@ -75,24 +326,423 @@ public sealed class RuntimeEntityObjectLifetime
&& IsCurrent();
}
public bool TryApplyObjDesc(
ObjDescEvent.Parsed update,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyObjDesc(update, out accepted);
if (!applied
|| !Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
return applied;
}
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvanceObjDescAuthority(canonical);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(RuntimeEntityChange.Updated, canonical);
return Entities.IsCurrent(canonical);
}
public bool TryApplyPickup(
PickupEvent.Parsed update,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyPickup(update, out accepted);
if (!applied
|| !Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
return applied;
}
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvancePositionAuthority(canonical);
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);
}
public bool TryApplyCreateParent(
CreateParentUpdate update,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyCreateParent(update, out accepted);
return CommitPositionChannelUpdate(
applied,
update.ChildGuid,
accepted,
acknowledgeProjection);
}
public bool TryApplyParent(
ParentEvent.Parsed update,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyParent(update, out accepted);
return CommitPositionChannelUpdate(
applied,
update.ChildGuid,
accepted,
acknowledgeProjection);
}
public bool TryCommitParent(
ParentAttachmentRelation relation,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool committed = Entities.TryCommitParent(
relation.ChildGuid,
relation.ParentGuid,
relation.ParentLocation,
relation.PlacementId,
relation.ChildPositionSequence,
out accepted);
if (!committed
|| !Entities.TryGetActive(
relation.ChildGuid,
out RuntimeEntityRecord canonical))
{
return committed;
}
Entities.RefreshSnapshot(canonical, accepted);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(RuntimeEntityChange.Updated, canonical);
return Entities.IsCurrent(canonical);
}
public bool CommitAcceptedParentCellless(
RuntimeEntityRecord canonical,
ulong positionAuthorityVersion,
Action<RuntimeEntityRecord>? acknowledgeProjection)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!Entities.IsCurrent(canonical)
|| canonical.PositionAuthorityVersion != positionAuthorityVersion)
{
return false;
}
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;
}
public bool TryApplyMotion(
WorldSession.EntityMotionUpdate update,
bool retainPayload,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyMotion(
update,
retainPayload,
out accepted,
out timestamps);
if (Entities.TryGetSnapshot(
update.Guid,
out WorldSession.EntitySpawn snapshot)
&& Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
Entities.RefreshSnapshot(canonical, snapshot);
if (applied && retainPayload)
Entities.AdvanceMovementAuthority(canonical);
acknowledgeProjection?.Invoke(canonical);
if (applied && Entities.IsCurrent(canonical))
PublishEntity(RuntimeEntityChange.Updated, canonical);
if (applied && !Entities.IsCurrent(canonical))
return false;
}
return applied;
}
public bool TryApplyVector(
VectorUpdate.Parsed update,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out WorldSession.EntitySpawn accepted)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyVector(update, out accepted);
if (!applied
|| !Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
return applied;
}
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvanceVectorAuthority(canonical);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(RuntimeEntityChange.Updated, canonical);
return Entities.IsCurrent(canonical);
}
public bool TryApplyState(
SetState.Parsed update,
Action<RuntimeEntityRecord, RetailPhysicsStateTransition>?
acknowledgeProjection,
out WorldSession.EntitySpawn accepted,
out RetailPhysicsStateTransition transition)
{
EnsureNotDisposed();
bool applied = Entities.TryApplyState(update, out accepted);
transition = default;
if (!applied
|| !Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
return applied;
}
Entities.RefreshSnapshot(canonical, accepted);
transition = Entities.ApplyRawPhysicsState(
canonical,
update.PhysicsState);
acknowledgeProjection?.Invoke(canonical, transition);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(
transition.HiddenTransition
is RetailHiddenTransition.BecameHidden
? RuntimeEntityChange.Hidden
: RuntimeEntityChange.Updated,
canonical);
return Entities.IsCurrent(canonical);
}
public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update,
bool isLocalPlayer,
System.Numerics.Quaternion? forcePositionRotation,
System.Numerics.Vector3? currentLocalVelocity,
bool projectionRequiresTeleportHook,
Action<RuntimeEntityRecord>? acknowledgeProjection,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps)
{
EnsureNotDisposed();
bool hadCanonical = Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord beforeCanonical);
uint beforeCell = beforeCanonical?.FullCellId ?? 0u;
bool wasCellless = hadCanonical && beforeCell == 0u;
bool known = Entities.TryApplyPosition(
update,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
out disposition,
out accepted,
out timestamps);
if (!known
|| !Entities.TryGetSnapshot(
update.Guid,
out WorldSession.EntitySpawn snapshot)
|| !Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
return known;
}
bool acceptedPosition =
disposition is not PositionTimestampDisposition.Rejected;
if (disposition is PositionTimestampDisposition.Apply)
{
timestamps = timestamps with
{
TeleportHookRequired =
timestamps.TeleportAdvanced
|| wasCellless
|| projectionRequiresTeleportHook,
};
}
Entities.RefreshSnapshot(
canonical,
snapshot,
refreshPosition: acceptedPosition);
if (acceptedPosition
&& ReferenceEquals(canonical, beforeCanonical))
{
Entities.AdvancePositionAuthority(canonical);
Entities.ParentAttachments.EndChildProjection(update.Guid);
}
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
if (acceptedPosition)
{
PublishEntity(
beforeCell != canonical.FullCellId
? RuntimeEntityChange.Rebucketed
: RuntimeEntityChange.Updated,
canonical);
}
return Entities.IsCurrent(canonical);
}
public bool CommitRebucket(
RuntimeEntityRecord canonical,
uint fullCellId,
uint canonicalLandblockId,
Action<RuntimeEntityRecord>? acknowledgeProjection = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!Entities.IsCurrent(canonical))
return false;
uint previous = canonical.FullCellId;
Entities.SetFullCell(
canonical,
fullCellId,
canonicalLandblockId);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
if (previous != fullCellId)
PublishEntity(RuntimeEntityChange.Rebucketed, canonical);
return Entities.IsCurrent(canonical);
}
public bool CommitWithdrawal(
RuntimeEntityRecord canonical,
Action<RuntimeEntityRecord>? acknowledgeProjection = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!Entities.IsCurrent(canonical))
return false;
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);
}
public bool CommitChildNoDraw(
RuntimeEntityRecord canonical,
bool noDraw,
Action<RuntimeEntityRecord>? acknowledgeProjection = null)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!Entities.IsCurrent(canonical))
return false;
Entities.SetChildNoDraw(canonical, noDraw);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(RuntimeEntityChange.Updated, canonical);
return Entities.IsCurrent(canonical);
}
public bool RetireAfterProjectionAcquisitionFailure(
RuntimeEntityRecord canonical)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical);
if (!Entities.RemoveActive(canonical))
return false;
Entities.AdvanceLifetimeMutation(canonical.ServerGuid);
PublishEntity(RuntimeEntityChange.Deleted, canonical);
return true;
}
/// <summary>
/// Accepts the exact DeleteObject generation without yet publishing the
/// retained-object removal. This split lets the graphical host retire the
/// active identity first, matching the established reentrant callback
/// order, while Runtime remains the only freshness authority.
/// Accepts and retires the exact canonical DeleteObject generation without
/// yet publishing the retained-object removal. This split lets a graphical
/// host tear down the exact retired projection before completing the
/// retained-object mutation while Runtime remains the only freshness,
/// identity, and canonical-lifetime authority.
/// </summary>
public bool TryAcceptDelete(
DeleteObject.Parsed delete,
bool isLocalPlayer,
bool removeRetainedObject,
out RuntimeEntityDeleteAcceptance acceptance)
{
EnsureNotDisposed();
if (!Entities.TryDelete(delete, isLocalPlayer))
{
acceptance = null!;
return false;
}
acceptance = new RuntimeEntityDeleteAcceptance(this, delete);
Entities.AdvanceLifetimeMutation(delete.Guid);
Entities.ParentAttachments.DeleteGeneration(
delete.Guid,
delete.InstanceSequence);
RuntimeEntityRecord? retiredCanonical = null;
if (Entities.TryGetActive(
delete.Guid,
out RuntimeEntityRecord active)
&& active.Incarnation == delete.InstanceSequence
&& Entities.RemoveActive(active))
{
retiredCanonical = active;
PublishEntity(
removeRetainedObject
? RuntimeEntityChange.Deleted
: RuntimeEntityChange.Withdrawn,
active);
}
acceptance = new RuntimeEntityDeleteAcceptance(
this,
delete,
retiredCanonical,
removeRetainedObject);
return true;
}
@ -104,6 +754,7 @@ public sealed class RuntimeEntityObjectLifetime
public void CompleteAcceptedDelete(
RuntimeEntityDeleteAcceptance acceptance)
{
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(acceptance);
if (!ReferenceEquals(acceptance.Owner, this))
{
@ -117,19 +768,132 @@ public sealed class RuntimeEntityObjectLifetime
}
acceptance.Completed = true;
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
if (acceptance.RemoveRetainedObject)
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
}
/// <summary>
/// Applies a delete accepted by the dormant exact-incarnation owner after
/// the active Runtime directory correctly reports no live record.
/// </summary>
public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete) =>
public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete)
{
EnsureNotDisposed();
ObjectTableWiring.ApplyEntityDelete(Objects, delete);
}
/// <summary>
/// Clears retained object state at the Runtime-owned reset stage. App
/// projection teardown remains a later acknowledged stage.
/// </summary>
public void ClearObjects() => Objects.Clear();
public void ClearObjects()
{
EnsureNotDisposed();
Objects.Clear();
}
public IReadOnlyList<RuntimeEntityRecord> BeginSessionClear()
{
EnsureNotDisposed();
if (_sessionClearInProgress)
return Array.Empty<RuntimeEntityRecord>();
_sessionClearInProgress = true;
Entities.BeginSessionClear();
RuntimeEntityRecord[] retired = Entities.ActiveRecords.ToArray();
foreach (RuntimeEntityRecord canonical in retired)
{
if (!Entities.RemoveActive(canonical))
continue;
PublishEntity(RuntimeEntityChange.Deleted, canonical);
}
return retired;
}
public bool CompleteSessionClearIfConverged()
{
EnsureNotDisposed();
if (!_sessionClearInProgress
|| !Entities.CompleteSessionClearIfConverged())
{
return false;
}
_sessionClearInProgress = false;
return true;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Events.Dispose();
}
private bool CommitPositionChannelUpdate(
bool applied,
uint guid,
WorldSession.EntitySpawn accepted,
Action<RuntimeEntityRecord>? acknowledgeProjection)
{
if (!applied
|| !Entities.TryGetActive(
guid,
out RuntimeEntityRecord canonical))
{
return applied;
}
Entities.RefreshSnapshot(canonical, accepted);
Entities.AdvancePositionAuthority(canonical);
acknowledgeProjection?.Invoke(canonical);
if (!Entities.IsCurrent(canonical))
return false;
PublishEntity(RuntimeEntityChange.Updated, canonical);
return Entities.IsCurrent(canonical);
}
private void PublishEntity(
RuntimeEntityChange change,
RuntimeEntityRecord canonical) =>
Events.PublishEntity(change, canonical);
private void EnsureNotDisposed() =>
ObjectDisposedException.ThrowIf(_disposed, this);
private static Exception? Combine(
Exception? first,
Exception? second) =>
first is null
? second
: second is null
? first
: new AggregateException(first, second);
private static InboundCreateResult SupersededCreateResult() => new(
CreateObjectTimestampDisposition.StaleGeneration,
default,
null,
default);
private bool IsCurrentOperation(
uint guid,
RuntimeEntityRecord canonical,
ulong sessionVersion,
ulong operationVersion) =>
Entities.SessionLifetimeVersion == sessionVersion
&& Entities.CurrentLifetimeMutation(guid) == operationVersion
&& Entities.IsCurrent(canonical);
private RuntimeEntityRegistrationResult SupersededRegistration(
uint guid,
bool replacedExistingGeneration) =>
new(
SupersededCreateResult(),
Entities.TryGetActive(guid, out RuntimeEntityRecord current)
? current
: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: replacedExistingGeneration);
}

View file

@ -0,0 +1,151 @@
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using System.Numerics;
namespace AcDream.Runtime.Entities;
/// <summary>
/// Allocation-free borrowed views over the canonical entity/object lifetime.
/// These views contain no App projection or backend state.
/// </summary>
internal sealed class RuntimeEntityObjectViews
{
public RuntimeEntityObjectViews(
RuntimeEntityDirectory entities,
ClientObjectTable objects)
{
Entities = new EntityView(
entities ?? throw new ArgumentNullException(nameof(entities)));
Inventory = new InventoryView(
entities,
objects ?? throw new ArgumentNullException(nameof(objects)));
}
public IRuntimeEntityView Entities { get; }
public IRuntimeInventoryView Inventory { get; }
internal static RuntimeEntitySnapshot Snapshot(RuntimeEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
uint localEntityId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Canonical entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no Runtime identity.");
return new RuntimeEntitySnapshot(
new RuntimeEntityIdentity(
record.ServerGuid,
localEntityId,
record.Incarnation),
record.FullCellId,
(uint)record.FinalPhysicsState,
ConvertPosition(record.Snapshot.Position));
}
private static Position? ConvertPosition(
CreateObject.ServerPosition? position) =>
position is not { } value
? null
: new Position(
value.LandblockId,
new Vector3(
value.PositionX,
value.PositionY,
value.PositionZ),
new Quaternion(
value.RotationX,
value.RotationY,
value.RotationZ,
value.RotationW));
internal static RuntimeInventoryItemSnapshot Snapshot(
ClientObject item,
RuntimeEntityDirectory entities,
ushort? exactGeneration = null)
{
ArgumentNullException.ThrowIfNull(item);
ushort incarnation = exactGeneration
?? (entities.TryGetActive(
item.ObjectId,
out RuntimeEntityRecord canonical)
? canonical.Incarnation
: (ushort)0);
return new RuntimeInventoryItemSnapshot(
item.ObjectId,
incarnation,
item.Name,
item.ContainerId,
item.ContainerSlot,
item.WielderId,
(uint)item.CurrentlyEquippedLocation,
item.StackSize,
item.Value);
}
private sealed class EntityView(RuntimeEntityDirectory owner)
: IRuntimeEntityView
{
public int Count => owner.Count;
public bool TryGet(
uint serverGuid,
out RuntimeEntitySnapshot entity)
{
if (owner.TryGetActive(
serverGuid,
out RuntimeEntityRecord record))
{
entity = Snapshot(record);
return true;
}
entity = default;
return false;
}
public void Visit(IRuntimeEntityVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (RuntimeEntityRecord record in owner.ActiveRecords)
{
RuntimeEntitySnapshot entity = Snapshot(record);
visitor.Visit(in entity);
}
}
}
private sealed class InventoryView(
RuntimeEntityDirectory entities,
ClientObjectTable owner)
: IRuntimeInventoryView
{
public int ObjectCount => owner.ObjectCount;
public int ContainerCount => owner.ContainerCount;
public bool TryGet(
uint objectId,
out RuntimeInventoryItemSnapshot item)
{
ClientObject? found = owner.Get(objectId);
if (found is not null)
{
item = Snapshot(found, entities);
return true;
}
item = default;
return false;
}
public void Visit(IRuntimeInventoryVisitor visitor)
{
ArgumentNullException.ThrowIfNull(visitor);
foreach (ClientObject item in owner.Objects)
{
RuntimeInventoryItemSnapshot snapshot = Snapshot(
item,
entities);
visitor.Visit(in snapshot);
}
}
}
}

View file

@ -225,12 +225,26 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver
/// <summary>Monotonic sequence owner for one runtime instance.</summary>
public sealed class RuntimeEventSequencer
{
private RuntimeGenerationToken _generation;
private bool _hasGeneration;
private ulong _sequence;
public RuntimeEventStamp Next(
RuntimeGenerationToken generation,
ulong frameNumber) =>
new(generation, checked(++_sequence), frameNumber);
ulong frameNumber)
{
if (!_hasGeneration || generation != _generation)
{
_generation = generation;
_sequence = 0;
_hasGeneration = true;
}
return new RuntimeEventStamp(
generation,
checked(++_sequence),
frameNumber);
}
public ulong LastSequence => _sequence;
}

View file

@ -11,10 +11,7 @@ public readonly record struct RuntimeEntitySnapshot(
RuntimeEntityIdentity Identity,
uint CellId,
uint PhysicsState,
Position? Position,
bool IsMaterialized,
bool IsSpatiallyVisible,
bool IsHydrated);
Position? Position);
public interface IRuntimeEntityVisitor
{
@ -25,8 +22,6 @@ public interface IRuntimeEntityView
{
int Count { get; }
int MaterializedCount { get; }
bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity);
/// <summary>

View file

@ -251,7 +251,7 @@ public sealed class LiveEntityInboundAuthorityGateTests
Assert.True(timestampAccepted);
Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord replacement));
Assert.Equal((ushort)7, replacement.Incarnation);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.False(runtime.TryGetRecord(Guid, out _));
}

View file

@ -469,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests
"new ResourceShutdownStage(\"frame borrowers\"",
"new ResourceShutdownStage(\"session dependents\"",
"new ResourceShutdownStage(\"live entities\"",
"new ResourceShutdownStage(\"runtime entity/object stream\"",
"new ResourceShutdownStage(\"effect dispatch edges\"",
"new ResourceShutdownStage(\"live entity dependents\"",
"new ResourceShutdownStage(\"submitted GPU work\"",

View file

@ -17,6 +17,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input;
@ -174,6 +175,172 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State);
}
[Fact]
public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace()
{
WorldSession.EntitySpawn spawn =
Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u);
var direct = new RuntimeEntityObjectLifetime();
direct.BindEventContext(static () => default, static () => 0UL);
var directTrace = new EntityObjectTrace();
using IDisposable directSubscription =
direct.Events.Subscribe(directTrace);
RuntimeEntityRegistrationResult directRegistration =
direct.RegisterEntity(spawn);
RuntimeEntityRecord directCanonical =
Assert.IsType<RuntimeEntityRecord>(directRegistration.Canonical);
direct.Objects.AddOrUpdate(Object(spawn));
direct.Objects.MoveItem(
spawn.Guid,
Harness.PlayerGuid,
newSlot: 3);
var vector = new VectorUpdate.Parsed(
spawn.Guid,
new Vector3(1f, 2f, 3f),
new Vector3(0f, 0f, 0.25f),
spawn.InstanceSequence,
VectorSequence: 2);
Assert.True(direct.TryApplyVector(
vector,
acknowledgeProjection: null,
out _));
var state = new SetState.Parsed(
spawn.Guid,
(uint)(PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Hidden),
spawn.InstanceSequence,
StateSequence: 2);
Assert.True(direct.TryApplyState(
state,
acknowledgeProjection: null,
out _,
out _));
WorldSession.EntityPositionUpdate position =
Position(spawn.Guid, spawn.InstanceSequence);
Assert.True(direct.TryApplyPosition(
position,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
projectionRequiresTeleportHook: false,
acknowledgeProjection: null,
out _,
out _,
out _));
Assert.True(direct.TryApplyPickup(
new PickupEvent.Parsed(
spawn.Guid,
spawn.InstanceSequence,
PositionSequence: 3),
acknowledgeProjection: null,
out _));
Assert.True(direct.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance directDelete));
direct.CompleteAcceptedDelete(directDelete);
Assert.Null(direct.RetireCanonicalOnly(directCanonical));
using var graphical = new Harness();
var graphicalTrace = new EntityObjectTrace();
using IDisposable graphicalSubscription =
graphical.EntityObjects.Events.Subscribe(graphicalTrace);
_ = graphical.Entities.RegisterAndMaterializeProjection(spawn);
graphical.Objects.AddOrUpdate(Object(spawn));
graphical.Objects.MoveItem(
spawn.Guid,
Harness.PlayerGuid,
newSlot: 3);
Assert.True(graphical.Entities.TryApplyVector(vector, out _));
Assert.True(graphical.Entities.TryApplyState(state, out _, out _));
Assert.True(graphical.Entities.TryApplyPosition(
position,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out _,
out _,
out _));
Assert.True(graphical.Entities.TryApplyPickup(
new PickupEvent.Parsed(
spawn.Guid,
spawn.InstanceSequence,
PositionSequence: 3),
out _));
Assert.True(graphical.Entities.UnregisterLiveEntity(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true));
Assert.Equal(directTrace.Entries, graphicalTrace.Entries);
Assert.Equal(0, direct.Entities.Count);
Assert.Equal(0, direct.Objects.ObjectCount);
Assert.Equal(0, graphical.EntityObjects.Entities.Count);
Assert.Equal(0, graphical.Objects.ObjectCount);
}
[Fact]
public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner()
{
using var harness = new Harness();
var throwing = new ThrowingEntityObserver();
var recording = new RuntimeTraceRecorder();
IDisposable first = harness.Runtime.Subscribe(throwing);
IDisposable second = harness.Runtime.Subscribe(recording);
LiveEntityRecord record =
harness.Entities.RegisterAndMaterializeProjection(Spawn(
Harness.TargetGuid,
instance: 4,
cell: 0x12340001u));
Assert.True(harness.EntityObjects.Entities.IsCurrent(record.Canonical));
Assert.Contains(
recording.Entries,
entry => entry.Kind == RuntimeTraceKind.Entity
&& entry.PrimaryObjectId == Harness.TargetGuid);
Assert.Equal(1, harness.EntityObjects.Events.SubscriberCount);
Assert.Equal(0, harness.EntityObjects.Events.DispatchFailureCount);
first.Dispose();
second.Dispose();
Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount);
}
private static ClientObject Object(WorldSession.EntitySpawn spawn) => new()
{
ObjectId = spawn.Guid,
Name = spawn.Name ?? string.Empty,
StackSize = 4,
Value = 25,
ContainerId = Harness.PlayerGuid,
ContainerSlot = 2,
};
private static WorldSession.EntityPositionUpdate Position(
uint guid,
ushort instance) =>
new(
guid,
new CreateObject.ServerPosition(
0x12350001u,
30f,
40f,
8f,
1f,
0f,
0f,
0f),
Velocity: new Vector3(1f, 2f, 3f),
PlacementId: null,
IsGrounded: true,
InstanceSequence: instance,
PositionSequence: 2,
TeleportSequence: 0,
ForcePositionSequence: 0);
private sealed class Harness : IDisposable
{
public const uint PlayerGuid = 0x50000002u;
@ -185,10 +352,12 @@ public sealed class CurrentGameRuntimeAdapterTests
public Harness()
{
Identity = new LocalPlayerIdentityState();
Entities = LiveEntityRuntimeFixture.Create(
EntityObjects = new RuntimeEntityObjectLifetime();
Entities = new LiveEntityRuntime(
new GpuWorldState(),
new NoopEntityResources());
Objects = new ClientObjectTable();
new NoopEntityResources(),
EntityObjects);
Objects = EntityObjects.Objects;
Chat = new ChatLog();
Selection = new SelectionState();
MovementInput = new DispatcherMovementInputSource();
@ -233,7 +402,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Commands,
Identity,
Entities,
Objects,
EntityObjects,
Chat,
new LocalPlayerControllerSlot(),
WorldReveal,
@ -246,6 +415,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeOptions Options { get; }
public LocalPlayerIdentityState Identity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; }
public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; }
public ChatLog Chat { get; }
@ -541,6 +711,64 @@ public sealed class CurrentGameRuntimeAdapterTests
ActivationType activation) => false;
}
private sealed class EntityObjectTrace : IRuntimeEntityObjectObserver
{
public List<string> Entries { get; } = [];
public void OnEntity(in RuntimeEntityDelta delta) =>
Entries.Add(
$"E:{delta.Stamp.Sequence}:{delta.Change}:"
+ $"{delta.Entity.Identity.ServerGuid:X8}:"
+ $"{delta.Entity.Identity.LocalEntityId}:"
+ $"{delta.Entity.Identity.Incarnation}:"
+ $"{delta.Entity.CellId:X8}:"
+ $"{delta.Entity.PhysicsState:X8}:"
+ $"{delta.Entity.Position?.ObjCellId:X8}:"
+ $"{delta.Entity.Position?.Frame.Origin.X}:"
+ $"{delta.Entity.Position?.Frame.Origin.Y}:"
+ $"{delta.Entity.Position?.Frame.Origin.Z}");
public void OnInventory(in RuntimeInventoryDelta delta) =>
Entries.Add(
$"I:{delta.Stamp.Sequence}:{delta.Change}:"
+ $"{delta.Item.ObjectId:X8}:"
+ $"{delta.Item.Incarnation}:"
+ $"{delta.Item.ContainerId:X8}:"
+ $"{delta.Item.ContainerSlot}:"
+ $"{delta.Item.StackSize}:"
+ $"{delta.Item.Value}");
}
private sealed class ThrowingEntityObserver : IRuntimeEventObserver
{
public void OnLifecycle(in RuntimeLifecycleDelta delta)
{
}
public void OnCommand(in RuntimeCommandDelta delta)
{
}
public void OnEntity(in RuntimeEntityDelta delta) =>
throw new InvalidOperationException("observer");
public void OnInventory(in RuntimeInventoryDelta delta)
{
}
public void OnChat(in RuntimeChatDelta delta)
{
}
public void OnMovement(in RuntimeMovementDelta delta)
{
}
public void OnPortal(in RuntimePortalDelta delta)
{
}
}
private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
{
public uint? PickAtCursor(bool includeSelf) => target;

View file

@ -1168,7 +1168,8 @@ public sealed class LandblockPresentationPipelineTests
nearRadius: 0,
farRadius: 0,
onLandblockLoaded: _ => recoveryCalls++,
ensureEnvCellMeshes: _ => replayCalls++);
ensureEnvCellMeshes: _ => replayCalls++,
workBudgetOptions: GenerousWorkBudget());
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x80, 0x80));
Assert.True(state.IsNearTier(landblockId));

View file

@ -80,7 +80,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(0, fixture.Resources.RegisterCount);
Assert.Empty(fixture.Materializer.Calls);
@ -134,7 +134,7 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(spawn);
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
@ -522,7 +522,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -852,7 +852,7 @@ public sealed class LiveEntityHydrationControllerTests
RuntimeEntityRecord replacement = fixture.Canonical;
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal("replacement", replacement.Snapshot.Name);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
}
@ -1368,7 +1368,7 @@ public sealed class LiveEntityHydrationControllerTests
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
RuntimeEntityRecord canonical = fixture.Canonical;
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Equal(0, fixture.Resources.RegisterCount);

View file

@ -312,7 +312,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests
Assert.True(runtime.TryGetCanonical(guid, out var replacement));
Assert.Equal((ushort)2, replacement.Incarnation);
Assert.Null(replacement.LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.Null(replacement.PhysicsBody);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Null(retired.RemoteMotionRuntime);

View file

@ -1656,7 +1656,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(runtime.TryGetRecord(spawn.Guid, out _));
RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid);
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.VisibleRecords);
Assert.Empty(spatial.Entities);
@ -1901,7 +1901,7 @@ public sealed class LiveEntityRuntimeTests
Assert.NotNull(replacement.PriorGenerationCleanupFailure);
RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, canonical.Generation);
Assert.Null(canonical.LocalEntityId);
Assert.NotNull(canonical.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
WorldEntity installed = runtime.MaterializeLiveEntity(
guid,
@ -2459,7 +2459,7 @@ public sealed class LiveEntityRuntimeTests
&& exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal));
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.DoesNotContain(
@ -2493,7 +2493,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(outer.LogicalRegistrationCreated);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
@ -2524,8 +2524,8 @@ public sealed class LiveEntityRuntimeTests
Assert.True(outer.LogicalRegistrationCreated);
RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)2, replacement.Generation);
Assert.Null(replacement.LocalEntityId);
Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
Assert.NotNull(replacement.LocalEntityId);
Assert.NotNull(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.False(runtime.TryGetRecord(unrelatedGuid, out _));
Assert.Equal(2, runtime.Count);
@ -2587,7 +2587,7 @@ public sealed class LiveEntityRuntimeTests
Assert.False(outer.LogicalRegistrationCreated);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2619,7 +2619,7 @@ public sealed class LiveEntityRuntimeTests
&& exception.Message == "old incarnation cleanup failed");
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2671,7 +2671,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Equal((ushort)1, current.Generation);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
@ -2695,7 +2695,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
RuntimeEntityRecord current = CurrentCanonical(runtime, guid);
Assert.Null(current.LocalEntityId);
Assert.NotNull(current.LocalEntityId);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);

View file

@ -4,8 +4,11 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities;
namespace AcDream.App.Tests.World;
@ -230,6 +233,46 @@ public sealed class RuntimeEntityOwnershipTests
property => IsPresentationType(property.PropertyType));
}
[Fact]
public void CurrentRuntimeAdapters_DoNotRetainEntityOrInventoryMirrors()
{
FieldInfo[] viewFields = typeof(CurrentGameRuntimeViewAdapter)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.Contains(
viewFields,
field => field.FieldType == typeof(IRuntimeEntityView));
Assert.Contains(
viewFields,
field => field.FieldType == typeof(IRuntimeInventoryView));
Assert.DoesNotContain(
typeof(CurrentGameRuntimeViewAdapter).GetNestedTypes(
BindingFlags.NonPublic),
type => type.Name is "EntityView" or "InventoryView");
FieldInfo[] eventFields = typeof(CurrentGameRuntimeEventAdapter)
.GetFields(BindingFlags.Instance | BindingFlags.NonPublic);
Assert.DoesNotContain(
eventFields,
field => field.FieldType == typeof(LiveEntityRuntime)
|| field.FieldType == typeof(ClientObjectTable)
|| field.FieldType == typeof(RuntimeEventSequencer));
Assert.Contains(
eventFields,
field => field.FieldType
== typeof(RuntimeEntityObjectLifetime));
string root = FindRepositoryRoot();
string liveSource = File.ReadAllText(Path.Combine(
root,
"src",
"AcDream.App",
"World",
"LiveEntityRuntime.cs"));
Assert.DoesNotContain("_entityObjects.PublishEntity", liveSource);
Assert.DoesNotContain("_directory.TryApply", liveSource);
Assert.DoesNotContain("_directory.RemoveActive", liveSource);
}
private static bool IsPresentationType(Type type)
{
string? ns = type.Namespace;

View file

@ -1,6 +1,7 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Items;
using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Tests.Entities;
@ -120,14 +121,19 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.False(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, 7),
isLocalPlayer: false,
removeRetainedObject: true,
out _));
Assert.NotNull(lifetime.Objects.Get(spawn.Guid));
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, 6),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance accepted));
Assert.Same(canonical, accepted.RetiredCanonical);
Assert.False(lifetime.Entities.TryGetActive(spawn.Guid, out _));
lifetime.CompleteAcceptedDelete(accepted);
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
Assert.Null(lifetime.Objects.Get(spawn.Guid));
}
@ -146,6 +152,7 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.True(owner.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance));
Assert.Throws<InvalidOperationException>(
@ -175,6 +182,509 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Same(canonical, retained);
}
[Fact]
public void CanonicalRegistration_IssuesIdentityBeforeAnyProjection()
{
var lifetime = new RuntimeEntityObjectLifetime();
WorldSession.EntitySpawn spawn = Spawn(
0x70000010u,
4,
"direct");
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
Assert.Equal(
RuntimeEntityDirectory.FirstLocalEntityId,
canonical.LocalEntityId);
Assert.True(lifetime.EntityView.TryGet(
spawn.Guid,
out RuntimeEntitySnapshot snapshot));
Assert.Equal(canonical.LocalEntityId, snapshot.Identity.LocalEntityId);
Assert.Equal(spawn.InstanceSequence, snapshot.Identity.Incarnation);
Assert.Equal(spawn.Position!.Value.LandblockId, snapshot.CellId);
}
[Fact]
public void EntityAndInventoryCommits_ShareOnePerGenerationSequence()
{
var lifetime = new RuntimeEntityObjectLifetime();
RuntimeGenerationToken generation = new(8);
ulong frame = 20;
lifetime.BindEventContext(() => generation, () => frame);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
WorldSession.EntitySpawn spawn = Spawn(
0x70000011u,
2,
"ordered");
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(spawn).Canonical!;
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
spawn,
replaceGeneration: false));
frame++;
lifetime.Objects.MoveItem(
spawn.Guid,
newContainerId: 0x50000001u,
newSlot: 3);
_ = lifetime.RegisterEntity(spawn);
Assert.Equal(
[
(RuntimeTraceKind.Entity, 1UL),
(RuntimeTraceKind.Inventory, 2UL),
(RuntimeTraceKind.Inventory, 3UL),
(RuntimeTraceKind.Entity, 4UL),
],
observer.Order);
Assert.All(
observer.Stamps,
stamp => Assert.Equal(generation, stamp.Generation));
Assert.Equal(20UL, observer.Stamps[0].FrameNumber);
Assert.Equal(21UL, observer.Stamps[^1].FrameNumber);
generation = new RuntimeGenerationToken(9);
lifetime.ClearObjects();
Assert.Equal(1UL, observer.Stamps[^1].Sequence);
Assert.Equal(generation, observer.Stamps[^1].Generation);
}
[Fact]
public void InventoryView_UsesCanonicalRuntimeIncarnationWithoutApp()
{
var lifetime = new RuntimeEntityObjectLifetime();
WorldSession.EntitySpawn spawn = Spawn(
0x70000012u,
12,
"inventory");
RuntimeEntityRecord canonical = Accept(lifetime, spawn);
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
spawn,
replaceGeneration: false));
Assert.True(lifetime.InventoryView.TryGet(
spawn.Guid,
out RuntimeInventoryItemSnapshot item));
Assert.Equal((ushort)12, item.Incarnation);
Assert.Equal("inventory", item.Name);
}
[Fact]
public void UnknownConfirmedMove_RetainsIdentityAndPlacementInStream()
{
var lifetime = new RuntimeEntityObjectLifetime();
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
Assert.False(lifetime.Objects.ApplyConfirmedServerMove(
0x7000DEADu,
newContainerId: 0x50000001u,
newWielderId: 0u,
newSlot: 7));
RuntimeInventoryDelta moved = Assert.Single(observer.Inventory);
Assert.Equal(RuntimeInventoryChange.Moved, moved.Change);
Assert.Equal(0x7000DEADu, moved.Item.ObjectId);
Assert.Equal(0x50000001u, moved.Item.ContainerId);
Assert.Equal(7, moved.Item.ContainerSlot);
}
[Fact]
public void EventSubscriptions_AreExactAndDisposeWithoutRetention()
{
var lifetime = new RuntimeEntityObjectLifetime();
var observer = new RecordingObserver();
IDisposable subscription = lifetime.Events.Subscribe(observer);
Assert.Equal(1, lifetime.Events.SubscriberCount);
Assert.Throws<InvalidOperationException>(
() => lifetime.Events.Subscribe(observer));
subscription.Dispose();
subscription.Dispose();
Assert.Equal(0, lifetime.Events.SubscriberCount);
}
[Fact]
public void Dispose_DetachesEveryObserverAndRejectsLaterDispatch()
{
var lifetime = new RuntimeEntityObjectLifetime();
var observer = new RecordingObserver();
_ = lifetime.Events.Subscribe(observer);
lifetime.Dispose();
lifetime.Dispose();
Assert.Equal(0, lifetime.Events.SubscriberCount);
Assert.Throws<ObjectDisposedException>(
() => lifetime.RegisterEntity(
Spawn(0x70000018u, 1, "disposed")));
lifetime.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x70000019u,
Name = "detached",
});
Assert.Empty(observer.Order);
}
[Fact]
public void ObserverFailure_DoesNotInterruptCommitOrLaterObservers()
{
var lifetime = new RuntimeEntityObjectLifetime();
var throwing = new CallbackObserver(
onEntity: static _ => throw new InvalidOperationException("observer"));
var recording = new RecordingObserver();
using IDisposable first = lifetime.Events.Subscribe(throwing);
using IDisposable second = lifetime.Events.Subscribe(recording);
RuntimeEntityRegistrationResult result =
lifetime.RegisterEntity(Spawn(0x70000015u, 1, "committed"));
Assert.True(result.LogicalRegistrationCreated);
Assert.NotNull(result.Canonical);
Assert.True(lifetime.Entities.IsCurrent(result.Canonical));
Assert.Single(recording.Entities);
Assert.Equal(RuntimeEntityChange.Registered, recording.Entities[0].Change);
Assert.Equal(1, lifetime.Events.DispatchFailureCount);
Assert.IsType<InvalidOperationException>(
lifetime.Events.LastDispatchFailure);
}
[Fact]
public void ObserverMutationDuringDispatch_AffectsOnlyLaterCommits()
{
var lifetime = new RuntimeEntityObjectLifetime();
var removed = new RecordingObserver();
var added = new RecordingObserver();
IDisposable? removedSubscription = null;
IDisposable? addedSubscription = null;
bool mutated = false;
var mutating = new CallbackObserver(
onEntity: _ =>
{
if (mutated)
return;
mutated = true;
removedSubscription!.Dispose();
addedSubscription = lifetime.Events.Subscribe(added);
});
using IDisposable first = lifetime.Events.Subscribe(mutating);
removedSubscription = lifetime.Events.Subscribe(removed);
RuntimeEntityRegistrationResult registration =
lifetime.RegisterEntity(Spawn(0x70000016u, 1, "first"));
_ = lifetime.RegisterEntity(registration.Canonical!.Snapshot);
Assert.Single(removed.Entities);
Assert.Single(added.Entities);
Assert.Equal(
RuntimeEntityChange.Registered,
removed.Entities[0].Change);
Assert.Equal(
RuntimeEntityChange.Updated,
added.Entities[0].Change);
addedSubscription!.Dispose();
Assert.Equal(1, lifetime.Events.SubscriberCount);
}
[Fact]
public void DirectDelete_PublishesTerminalEntityBeforeInventoryRemoval()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(4), static () => 11);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
WorldSession.EntitySpawn spawn =
Spawn(0x70000017u, 5, "delete");
RuntimeEntityRegistrationResult registration =
lifetime.RegisterEntity(spawn);
RuntimeEntityRecord canonical = registration.Canonical!;
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
spawn,
replaceGeneration: true));
Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance));
lifetime.CompleteAcceptedDelete(acceptance);
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
Assert.Equal(
[
(RuntimeTraceKind.Entity, 1UL),
(RuntimeTraceKind.Inventory, 2UL),
(RuntimeTraceKind.Entity, 3UL),
(RuntimeTraceKind.Inventory, 4UL),
],
observer.Order);
Assert.Equal(
RuntimeEntityChange.Deleted,
observer.Entities[^1].Change);
Assert.False(lifetime.EntityView.TryGet(spawn.Guid, out _));
Assert.False(lifetime.InventoryView.TryGet(spawn.Guid, out _));
Assert.Equal(0, lifetime.Entities.ClaimedLocalIdCount);
}
[Fact]
public void SessionClear_SealsOldGenerationAndNextStartsAtOne()
{
var lifetime = new RuntimeEntityObjectLifetime();
RuntimeGenerationToken generation = new(12);
lifetime.BindEventContext(() => generation, static () => 30UL);
var observer = new RecordingObserver();
bool rejectedReentrantCreate = false;
var reentrant = new CallbackObserver(
onEntity: delta =>
{
if (delta.Change is not RuntimeEntityChange.Deleted)
return;
rejectedReentrantCreate = Assert.Throws<InvalidOperationException>(
() => lifetime.RegisterEntity(
Spawn(delta.Entity.Identity.ServerGuid, 2, "stale")))
is not null;
});
using IDisposable first = lifetime.Events.Subscribe(reentrant);
using IDisposable second = lifetime.Events.Subscribe(observer);
WorldSession.EntitySpawn spawn =
Spawn(0x70000020u, 1, "old");
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(spawn).Canonical!;
Assert.True(lifetime.ApplyAcceptedSpawn(
canonical,
canonical.CreateIntegrationVersion,
spawn,
replaceGeneration: true));
IReadOnlyList<RuntimeEntityRecord> retired =
lifetime.BeginSessionClear();
lifetime.ClearObjects();
Assert.Null(lifetime.RetireCanonicalOnly(Assert.Single(retired)));
Assert.True(lifetime.CompleteSessionClearIfConverged());
generation = new RuntimeGenerationToken(13);
RuntimeEntityRecord replacement =
lifetime.RegisterEntity(Spawn(spawn.Guid, 2, "new")).Canonical!;
Assert.True(rejectedReentrantCreate);
Assert.Equal((ushort)2, replacement.Incarnation);
Assert.Equal(
[
(new RuntimeGenerationToken(12), 1UL),
(new RuntimeGenerationToken(12), 2UL),
(new RuntimeGenerationToken(12), 3UL),
(new RuntimeGenerationToken(12), 4UL),
(new RuntimeGenerationToken(13), 1UL),
],
observer.Stamps
.Select(stamp => (stamp.Generation, stamp.Sequence))
.ToArray());
}
[Fact]
public void RegisterEntity_DirectHostPublishesCompleteGenerationOrder()
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.BindEventContext(static () => new(3), static () => 9);
var observer = new RecordingObserver();
using IDisposable subscription = lifetime.Events.Subscribe(observer);
const uint guid = 0x70000013u;
RuntimeEntityRegistrationResult first =
lifetime.RegisterEntity(Spawn(guid, 1, "first"));
RuntimeEntityRegistrationResult refresh =
lifetime.RegisterEntity(Spawn(guid, 1, "refresh"));
RuntimeEntityRegistrationResult replacement =
lifetime.RegisterEntity(Spawn(guid, 2, "replacement"));
Assert.True(first.LogicalRegistrationCreated);
Assert.False(refresh.LogicalRegistrationCreated);
Assert.True(replacement.ReplacedExistingGeneration);
Assert.Equal(
[
RuntimeEntityChange.Registered,
RuntimeEntityChange.Updated,
RuntimeEntityChange.Deleted,
RuntimeEntityChange.Registered,
],
observer.Entities.Select(delta => delta.Change).ToArray());
Assert.Equal(
[1UL, 2UL, 3UL, 4UL],
observer.Entities
.Select(delta => delta.Stamp.Sequence)
.ToArray());
Assert.Equal(
(ushort)2,
lifetime.EntityView.TryGet(guid, out RuntimeEntitySnapshot current)
? current.Identity.Incarnation
: (ushort)0);
Assert.Equal(0, lifetime.Entities.PendingTeardownCount);
}
[Fact]
public void RegisterEntity_ReentrantNewerGenerationSupersedesOuterCreate()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000014u;
_ = lifetime.RegisterEntity(Spawn(guid, 1, "first"));
bool nested = false;
var observer = new CallbackObserver(
onEntity: delta =>
{
if (nested
|| delta.Change is not RuntimeEntityChange.Deleted)
{
return;
}
nested = true;
_ = lifetime.RegisterEntity(Spawn(guid, 3, "nested"));
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRegistrationResult outer =
lifetime.RegisterEntity(Spawn(guid, 2, "outer"));
Assert.Equal(
CreateObjectTimestampDisposition.StaleGeneration,
outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.True(lifetime.Entities.TryGetActive(
guid,
out RuntimeEntityRecord current));
Assert.Equal((ushort)3, current.Incarnation);
Assert.NotNull(current.LocalEntityId);
}
[Fact]
public void RegisterEntity_ReentrantDeleteSupersedesSameGenerationRefresh()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000021u;
RuntimeEntityRecord first =
lifetime.RegisterEntity(Spawn(guid, 1, "first")).Canonical!;
bool deleted = false;
var observer = new CallbackObserver(
onEntity: delta =>
{
if (deleted
|| delta.Change is not RuntimeEntityChange.Updated)
{
return;
}
deleted = lifetime.TryAcceptDelete(
new DeleteObject.Parsed(guid, 1),
isLocalPlayer: false,
removeRetainedObject: false,
out RuntimeEntityDeleteAcceptance acceptance);
if (deleted)
{
lifetime.CompleteAcceptedDelete(acceptance);
Assert.Null(lifetime.RetireCanonicalOnly(first));
}
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRegistrationResult refresh =
lifetime.RegisterEntity(Spawn(guid, 1, "refresh"));
Assert.True(deleted);
Assert.Equal(
CreateObjectTimestampDisposition.StaleGeneration,
refresh.Inbound.Disposition);
Assert.Null(refresh.Canonical);
Assert.False(lifetime.Entities.TryGetActive(guid, out _));
}
[Fact]
public void RegisterEntity_ReentrantReplacementSupersedesInitialCommit()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000022u;
bool replaced = false;
var observer = new CallbackObserver(
onEntity: delta =>
{
if (replaced
|| delta.Change is not RuntimeEntityChange.Registered
|| delta.Entity.Identity.Incarnation != 1)
{
return;
}
replaced = true;
_ = lifetime.RegisterEntity(Spawn(guid, 2, "replacement"));
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
RuntimeEntityRegistrationResult outer =
lifetime.RegisterEntity(Spawn(guid, 1, "outer"));
Assert.True(replaced);
Assert.Equal(
CreateObjectTimestampDisposition.StaleGeneration,
outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.Equal(
(ushort)2,
Assert.IsType<RuntimeEntityRecord>(outer.Canonical).Incarnation);
}
[Fact]
public void AcceptedUpdate_RevalidatesAfterReentrantTerminalObserver()
{
var lifetime = new RuntimeEntityObjectLifetime();
const uint guid = 0x70000023u;
RuntimeEntityRecord canonical =
lifetime.RegisterEntity(Spawn(guid, 1, "vector")).Canonical!;
bool deleted = false;
var observer = new CallbackObserver(
onEntity: delta =>
{
if (deleted
|| delta.Change is not RuntimeEntityChange.Updated)
{
return;
}
deleted = lifetime.TryAcceptDelete(
new DeleteObject.Parsed(guid, 1),
isLocalPlayer: false,
removeRetainedObject: false,
out RuntimeEntityDeleteAcceptance acceptance);
if (deleted)
{
lifetime.CompleteAcceptedDelete(acceptance);
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
}
});
using IDisposable subscription = lifetime.Events.Subscribe(observer);
bool remainedCurrent = lifetime.TryApplyVector(
new VectorUpdate.Parsed(
guid,
new System.Numerics.Vector3(1f, 0f, 0f),
System.Numerics.Vector3.Zero,
InstanceSequence: 1,
VectorSequence: 2),
acknowledgeProjection: null,
out _);
Assert.True(deleted);
Assert.False(remainedCurrent);
Assert.False(lifetime.Entities.TryGetActive(guid, out _));
}
private static RuntimeEntityRecord Accept(
RuntimeEntityObjectLifetime lifetime,
WorldSession.EntitySpawn spawn)
@ -248,4 +758,38 @@ public sealed class RuntimeEntityObjectLifetimeTests
PositionSequence: 1,
Physics: physics);
}
private sealed class RecordingObserver : IRuntimeEntityObjectObserver
{
public List<(RuntimeTraceKind Kind, ulong Sequence)> Order { get; } = [];
public List<RuntimeEventStamp> Stamps { get; } = [];
public List<RuntimeEntityDelta> Entities { get; } = [];
public List<RuntimeInventoryDelta> Inventory { get; } = [];
public void OnEntity(in RuntimeEntityDelta delta)
{
Order.Add((RuntimeTraceKind.Entity, delta.Stamp.Sequence));
Stamps.Add(delta.Stamp);
Entities.Add(delta);
}
public void OnInventory(in RuntimeInventoryDelta delta)
{
Order.Add((RuntimeTraceKind.Inventory, delta.Stamp.Sequence));
Stamps.Add(delta.Stamp);
Inventory.Add(delta);
}
}
private sealed class CallbackObserver(
Action<RuntimeEntityDelta>? onEntity = null,
Action<RuntimeInventoryDelta>? onInventory = null)
: IRuntimeEntityObjectObserver
{
public void OnEntity(in RuntimeEntityDelta delta) =>
onEntity?.Invoke(delta);
public void OnInventory(in RuntimeInventoryDelta delta) =>
onInventory?.Invoke(delta);
}
}

View file

@ -80,6 +80,21 @@ public sealed class GameRuntimeContractTests
Assert.Equal(99UL, independent.FrameNumber);
}
[Fact]
public void EventSequencer_RestartsAtOneForEachSessionGeneration()
{
var sequencer = new RuntimeEventSequencer();
RuntimeEventStamp first = sequencer.Next(new(4), 10);
RuntimeEventStamp second = sequencer.Next(new(4), 11);
RuntimeEventStamp replacement = sequencer.Next(new(5), 12);
Assert.Equal(1UL, first.Sequence);
Assert.Equal(2UL, second.Sequence);
Assert.Equal(1UL, replacement.Sequence);
Assert.Equal(new RuntimeGenerationToken(5), replacement.Generation);
}
[Fact]
public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners()
{