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, liveSessionCommands,
d.PlayerIdentity, d.PlayerIdentity,
live.LiveEntities, live.LiveEntities,
d.EntityObjects.Objects, d.EntityObjects,
d.Chat, d.Chat,
d.PlayerController, d.PlayerController,
worldReveal, worldReveal,

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ using AcDream.App.World;
using AcDream.Core.Chat; using AcDream.Core.Chat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
namespace AcDream.App.Runtime; namespace AcDream.App.Runtime;
@ -21,8 +22,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly ChatLog _chat; private readonly ChatLog _chat;
private readonly IGameRuntimeClock _clock; private readonly IGameRuntimeClock _clock;
private readonly EntityView _entityView; private readonly IRuntimeEntityView _entityView;
private readonly InventoryView _inventoryView; private readonly IRuntimeInventoryView _inventoryView;
private readonly ChatView _chatView; private readonly ChatView _chatView;
private readonly MovementView _movementView; private readonly MovementView _movementView;
private readonly PortalView _portalView; private readonly PortalView _portalView;
@ -32,7 +33,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
LiveSessionController session, LiveSessionController session,
LocalPlayerIdentityState playerIdentity, LocalPlayerIdentityState playerIdentity,
LiveEntityRuntime entities, LiveEntityRuntime entities,
ClientObjectTable objects, RuntimeEntityObjectLifetime entityObjects,
ChatLog chat, ChatLog chat,
ILocalPlayerControllerSource playerController, ILocalPlayerControllerSource playerController,
WorldRevealCoordinator worldReveal, WorldRevealCoordinator worldReveal,
@ -42,12 +43,13 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
_playerIdentity = playerIdentity _playerIdentity = playerIdentity
?? throw new ArgumentNullException(nameof(playerIdentity)); ?? throw new ArgumentNullException(nameof(playerIdentity));
_entities = entities ?? throw new ArgumentNullException(nameof(entities)); _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)); _chat = chat ?? throw new ArgumentNullException(nameof(chat));
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); _clock = clock ?? throw new ArgumentNullException(nameof(clock));
_entityView = new EntityView(_entities); _entityView = entityObjects.EntityView;
_inventoryView = new InventoryView(_objects, _entities); _inventoryView = entityObjects.InventoryView;
_chatView = new ChatView(_chat); _chatView = new ChatView(_chat);
_movementView = new MovementView( _movementView = new MovementView(
playerController playerController
@ -108,103 +110,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView
internal void Deactivate() => _active = false; 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 private sealed class ChatView(ChatLog owner) : IRuntimeChatView
{ {
public long Revision => owner.Revision; public long Revision => owner.Revision;

View file

@ -3,6 +3,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
using System.Numerics; using System.Numerics;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
@ -377,29 +378,13 @@ public sealed class LiveEntityRecord
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) => internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) =>
_directory.ApplyRawPhysicsState(Canonical, rawState); _directory.ApplyRawPhysicsState(Canonical, rawState);
internal void AdvancePositionAuthority() => internal void NoteObjDescProjectionSynchronization()
_directory.AdvancePositionAuthority(Canonical);
internal void AdvanceVectorAuthority() =>
_directory.AdvanceVectorAuthority(Canonical);
internal void AdvanceMovementAuthority() =>
_directory.AdvanceMovementAuthority(Canonical);
internal void AdvanceObjDescAuthority()
{ {
_directory.AdvanceObjDescAuthority(Canonical);
AppearanceProjectionSynchronizationPending = true; AppearanceProjectionSynchronizationPending = true;
if (AppearanceHydrationInProgress) if (AppearanceHydrationInProgress)
AppearanceHydrationRetryRequested = true; AppearanceHydrationRetryRequested = true;
} }
internal void AdvanceCreateAuthority() =>
_directory.AdvanceCreateAuthority(Canonical);
internal void SetChildNoDraw(bool noDraw) =>
_directory.SetChildNoDraw(Canonical, noDraw);
/// <summary> /// <summary>
/// Cell used when a streamed landblock asks live objects to restore their /// Cell used when a streamed landblock asks live objects to restore their
/// spatial projection. Once materialized, the canonical runtime cell wins; /// 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."); : "A live entity cannot register from inside atomic resource registration.");
} }
InboundCreateResult result = _directory.AcceptCreate(incoming); RuntimeEntityRegistrationResult registration =
if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) _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))
{
RefreshSpatialRuntimeIndexes(projection);
}
return new LiveEntityRegistrationResult( return new LiveEntityRegistrationResult(
result, registration.Inbound,
Canonical: null,
Projection: null,
LogicalRegistrationCreated: false,
ReplacedExistingGeneration: false);
ulong sessionVersion = _directory.SessionLifetimeVersion;
ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid);
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
{
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);
}
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,
canonical, canonical,
Projection: null, projection,
true, registration.LogicalRegistrationCreated,
replaced, registration.ReplacedExistingGeneration,
tearDownFailure); registration.PriorGenerationCleanupFailure);
} }
/// <summary> /// <summary>
@ -785,7 +646,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool createdSidecar = false; bool createdSidecar = false;
if (!_projections.TryGet(expectedCanonical, out record)) 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 try
{ {
record = _projections.AddMaterializing(expectedCanonical); record = _projections.AddMaterializing(expectedCanonical);
@ -800,7 +663,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
record.ProjectionKey = null; record.ProjectionKey = null;
record = null; record = null;
} }
_directory.ReleaseLocalId(expectedCanonical);
throw; throw;
} }
} }
@ -833,7 +695,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
_projections.RemoveActive(record); _projections.RemoveActive(record);
record.ProjectionKey = null; record.ProjectionKey = null;
_directory.ReleaseLocalId(expectedCanonical);
record = null; record = null;
} }
throw; throw;
@ -870,14 +731,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
_projections.RemoveActive(record); _projections.RemoveActive(record);
record.ProjectionKey = null; record.ProjectionKey = null;
_directory.ReleaseLocalId(expectedCanonical);
record.WorldEntity = null; record.WorldEntity = null;
record = null; record = null;
throw; throw;
} }
if (_directory.IsCurrent(expectedCanonical)) _entityObjects.RetireAfterProjectionAcquisitionFailure(
_directory.RemoveActive(expectedCanonical); expectedCanonical);
if (_projections.RemoveActive(record)) if (_projections.RemoveActive(record))
{ {
RetainTeardownRecord(record); RetainTeardownRecord(record);
@ -983,11 +843,24 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool visible = _spatial.IsLiveEntityProjectionResident(key); bool visible = _spatial.IsLiveEntityProjectionResident(key);
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
RefreshPresentation(record); RefreshPresentation(record);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) uint committedFullCell =
record.FullCellId = spatialCellOrLandblockId; (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu
record.CanonicalLandblockId = spatialCellOrLandblockId == 0 ? spatialCellOrLandblockId
: record.FullCellId;
uint committedLandblock = spatialCellOrLandblockId == 0
? 0u ? 0u
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_entityObjects.CommitRebucket(
record.Canonical,
committedFullCell,
committedLandblock))
{
ThrowAfterCommittedProjectionChange(
serverGuid,
spatialNotificationFailure,
runtimeNotificationFailure: null);
return false;
}
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
&& record.IsSpatiallyProjected && record.IsSpatiallyProjected
&& record.IsSpatiallyVisible && record.IsSpatiallyVisible
@ -1182,7 +1055,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false; return false;
} }
ClearWorldCell(serverGuid); if (!_entityObjects.CommitWithdrawal(
record.Canonical,
AcknowledgeCelllessCanonicalCommit))
{
return false;
}
record.WorldEntity.ParentCellId = 0u; record.WorldEntity.ParentCellId = 0u;
return WithdrawLiveEntityProjection(serverGuid); return WithdrawLiveEntityProjection(serverGuid);
} }
@ -1218,10 +1096,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
if (!_entityObjects.TryAcceptDelete( if (!_entityObjects.TryAcceptDelete(
delete, delete,
isLocalPlayer, isLocalPlayer,
removeRetainedObject,
out acceptedDelete)) out acceptedDelete))
return false; return false;
AdvanceLifetimeMutation(delete.Guid);
ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence);
// End active gameplay identity before arbitrary callbacks so a // End active gameplay identity before arbitrary callbacks so a
// newer generation can be accepted re-entrantly, but retain the // 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 // succeeded. A throwing resource callback can then be retried by
// the same accepted Delete instead of orphaning its owner. // the same accepted Delete instead of orphaning its owner.
LiveEntityRecord? retainedRegistrationFailure = record; LiveEntityRecord? retainedRegistrationFailure = record;
if (_directory.TryGetActive( if (acceptedDelete.RetiredCanonical is { } activeCanonical)
delete.Guid,
out RuntimeEntityRecord activeCanonical)
&& activeCanonical.Incarnation == delete.InstanceSequence
&& _directory.RemoveActive(activeCanonical))
{ {
if (_projections.TryGet( if (_projections.TryGet(
activeCanonical, activeCanonical,
@ -1265,8 +1138,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
try try
{ {
if (!retryingAcceptedDelete) if (!retryingAcceptedDelete)
{
if (removeRetainedObject)
{ {
try try
{ {
@ -1276,7 +1147,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
(failures ??= new List<Exception>()).Add(error); (failures ??= new List<Exception>()).Add(error);
} }
}
try try
{ {
@ -1872,95 +1742,44 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_directory.TryGetSnapshot(guid, out spawn); _directory.TryGetSnapshot(guid, out spawn);
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
=> _entityObjects.TryApplyObjDesc(
update,
canonical =>
{ {
bool applied = _directory.TryApplyObjDesc(update, out accepted); if (_projections.TryGet(
if (applied canonical,
&& _directory.TryGetActive( out LiveEntityRecord? record))
update.Guid,
out RuntimeEntityRecord canonical))
{ {
_directory.RefreshSnapshot(canonical, accepted); record.NoteObjDescProjectionSynchronization();
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvanceObjDescAuthority();
else
_directory.AdvanceObjDescAuthority(canonical);
}
return applied;
} }
},
out accepted);
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{ => _entityObjects.TryApplyPickup(
bool applied = _directory.TryApplyPickup(update, out accepted); update,
if (applied AcknowledgeCelllessCanonicalCommit,
&& _directory.TryGetActive( out accepted);
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;
}
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted) public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
{ => _entityObjects.TryApplyCreateParent(
bool applied = _directory.TryApplyCreateParent(update, out accepted); update,
if (applied acknowledgeProjection: null,
&& _directory.TryGetActive( out accepted);
update.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
else
_directory.AdvancePositionAuthority(canonical);
}
return applied;
}
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
{ => _entityObjects.TryApplyParent(
bool applied = _directory.TryApplyParent(update, out accepted); update,
if (applied acknowledgeProjection: null,
&& _directory.TryGetActive( out accepted);
update.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvancePositionAuthority();
else
_directory.AdvancePositionAuthority(canonical);
}
return applied;
}
internal bool CommitStagedParent( internal bool CommitStagedParent(
ParentAttachmentRelation relation, ParentAttachmentRelation relation,
out WorldSession.EntitySpawn accepted) out WorldSession.EntitySpawn accepted)
{ => _entityObjects.TryCommitParent(
bool committed = _directory.TryCommitParent( relation,
relation.ChildGuid, acknowledgeProjection: null,
relation.ParentGuid,
relation.ParentLocation,
relation.PlacementId,
relation.ChildPositionSequence,
out accepted); out accepted);
if (committed
&& _directory.TryGetActive(
relation.ChildGuid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
}
return committed;
}
/// <summary> /// <summary>
/// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only /// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only
@ -1973,25 +1792,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ulong positionAuthorityVersion) ulong positionAuthorityVersion)
{ {
ArgumentNullException.ThrowIfNull(record); ArgumentNullException.ThrowIfNull(record);
if (!IsCurrentPositionAuthority(record, positionAuthorityVersion)) return _entityObjects.CommitAcceptedParentCellless(
return false; record.Canonical,
ClearWorldCell(record.ServerGuid); positionAuthorityVersion,
return IsCurrentPositionAuthority(record, positionAuthorityVersion); AcknowledgeCelllessCanonicalCommit);
} }
internal bool CommitAcceptedParentCellless( internal bool CommitAcceptedParentCellless(
RuntimeEntityRecord canonical, RuntimeEntityRecord canonical,
ulong positionAuthorityVersion) ulong positionAuthorityVersion)
{ {
ArgumentNullException.ThrowIfNull(canonical); return _entityObjects.CommitAcceptedParentCellless(
if (!_directory.IsCurrent(canonical) canonical,
|| canonical.PositionAuthorityVersion != positionAuthorityVersion) positionAuthorityVersion,
{ AcknowledgeCelllessCanonicalCommit);
return false;
}
ClearWorldCell(canonical);
return _directory.IsCurrent(canonical)
&& canonical.PositionAuthorityVersion == positionAuthorityVersion;
} }
public bool TryApplyMotion( public bool TryApplyMotion(
@ -1999,43 +1813,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
bool retainPayload, bool retainPayload,
out WorldSession.EntitySpawn accepted, out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps) out AcceptedPhysicsTimestamps timestamps)
{ => _entityObjects.TryApplyMotion(
bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps); update,
if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot) retainPayload,
&& _directory.TryGetActive( acknowledgeProjection: null,
update.Guid, out accepted,
out RuntimeEntityRecord canonical)) out timestamps);
{
_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;
}
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
{ => _entityObjects.TryApplyVector(
bool applied = _directory.TryApplyVector(update, out accepted); update,
if (applied acknowledgeProjection: null,
&& _directory.TryGetActive( out accepted);
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
record.AdvanceVectorAuthority();
else
_directory.AdvanceVectorAuthority(canonical);
}
return applied;
}
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted) public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
=> TryApplyState(update, out accepted, out _); => TryApplyState(update, out accepted, out _);
@ -2044,23 +1833,19 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
SetState.Parsed update, SetState.Parsed update,
out WorldSession.EntitySpawn accepted, out WorldSession.EntitySpawn accepted,
out RetailPhysicsStateTransition transition) out RetailPhysicsStateTransition transition)
=> _entityObjects.TryApplyState(
update,
(canonical, _) =>
{ {
bool applied = _directory.TryApplyState(update, out accepted); if (_projections.TryGet(
transition = default;
if (applied
&& _directory.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical))
{
_directory.RefreshSnapshot(canonical, accepted);
transition = _directory.ApplyRawPhysicsState(
canonical, canonical,
update.PhysicsState); out LiveEntityRecord? record))
if (_projections.TryGet(canonical, out LiveEntityRecord? record)) {
RefreshPresentation(record); RefreshPresentation(record);
} }
return applied; },
} out accepted,
out transition);
/// <summary> /// <summary>
/// Retail parent Hidden directly toggles each attached child's NoDraw bit /// 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)) if (!_projections.TryGetCurrent(childServerGuid, out LiveEntityRecord? record))
return false; return false;
record.SetChildNoDraw(noDraw); return _entityObjects.CommitChildNoDraw(
RefreshPresentation(record); record.Canonical,
return true; noDraw,
_ => RefreshPresentation(record));
} }
public bool TryApplyPosition( public bool TryApplyPosition(
@ -2084,62 +1870,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
out WorldSession.EntitySpawn accepted, out WorldSession.EntitySpawn accepted,
out AcceptedPhysicsTimestamps timestamps) out AcceptedPhysicsTimestamps timestamps)
{ {
bool hadCanonical = _directory.TryGetActive( bool projectionRequiresTeleportHook =
_directory.TryGetActive(
update.Guid, update.Guid,
out RuntimeEntityRecord? beforeCanonical); out RuntimeEntityRecord canonical)
LiveEntityRecord? beforePosition = null; && (canonical.FullCellId == 0u
if (hadCanonical) || !_projections.TryGet(
_projections.TryGet(beforeCanonical!, out beforePosition); canonical,
bool wasCellLess = hadCanonical out LiveEntityRecord? projection)
&& (beforeCanonical!.FullCellId == 0 || !projection.IsSpatiallyProjected
|| beforePosition is null || !projection.IsSpatiallyVisible);
|| !beforePosition.IsSpatiallyProjected return _entityObjects.TryApplyPosition(
|| !beforePosition.IsSpatiallyVisible);
bool known = _directory.TryApplyPosition(
update, update,
isLocalPlayer, isLocalPlayer,
forcePositionRotation, forcePositionRotation,
currentLocalVelocity, currentLocalVelocity,
projectionRequiresTeleportHook,
acknowledgeProjection: null,
out disposition, out disposition,
out accepted, out accepted,
out timestamps); 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) => public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
@ -2669,15 +2419,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_isClearing = true; _isClearing = true;
_sessionClearPendingFinalization = true; _sessionClearPendingFinalization = true;
_directory.BeginSessionClear(); IReadOnlyList<RuntimeEntityRecord> retiredCanonicals =
_entityObjects.BeginSessionClear();
List<Exception>? failures = null; List<Exception>? failures = null;
try try
{ {
foreach (RuntimeEntityRecord canonical in foreach (RuntimeEntityRecord canonical in retiredCanonicals)
_directory.ActiveRecords.ToArray())
{ {
if (!_directory.RemoveActive(canonical))
continue;
if (_projections.TryGet( if (_projections.TryGet(
canonical, canonical,
out LiveEntityRecord? record)) out LiveEntityRecord? record))
@ -2796,12 +2544,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_directory.RefreshSnapshot(canonical, accepted, refreshPosition); _directory.RefreshSnapshot(canonical, accepted, refreshPosition);
} }
private static InboundCreateResult SupersededCreateResult() => new(
CreateObjectTimestampDisposition.StaleGeneration,
default,
null,
default);
private ulong AdvanceLifetimeMutation(uint serverGuid) private ulong AdvanceLifetimeMutation(uint serverGuid)
=> _directory.AdvanceLifetimeMutation(serverGuid); => _directory.AdvanceLifetimeMutation(serverGuid);
@ -2841,37 +2583,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
ExceptionDispatchInfo.Capture(failure).Throw(); ExceptionDispatchInfo.Capture(failure).Throw();
} }
private void ClearWorldCell(uint guid) private void AcknowledgeCelllessCanonicalCommit(
RuntimeEntityRecord canonical)
{
if (!_directory.IsCurrent(canonical)
|| !_projections.TryGet(
canonical,
out LiveEntityRecord? record))
{ {
if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical))
return; 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); SynchronizePhysicsBodyActiveState(record);
}
_directory.SetFullCell(canonical, 0, 0);
RefreshSpatialRuntimeIndexes(record); RefreshSpatialRuntimeIndexes(record);
} }
@ -3091,27 +2814,50 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
_projections.RetainTeardown(record); _projections.RetainTeardown(record);
} }
private void TearDownCanonicalOnly(RuntimeEntityRecord canonical) private Exception? RetirePriorProjection(
RuntimeEntityRecord canonical)
{ {
// A canonical-only incarnation has never crossed the App projection if (!_projections.TryGet(
// acquisition edge, so it owns no renderer/effect/animation suffix. canonical,
// Retain it briefly only so Runtime's validated mutators can converge out LiveEntityRecord? projection))
// any presentation-free physics state before the tombstone vanishes. {
_directory.RetainTeardown(canonical); 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 try
{ {
_directory.SetPhysicsHost(canonical, null); try
_directory.SetPhysicsBody(canonical, null); {
_directory.SetPhysicsBodyAcquisitionInProgress(canonical, false); TearDownRecord(projection);
_directory.SetHasPartArray(canonical, false); ReleaseTeardownRecord(projection);
_directory.ReleaseLocalId(canonical); return null;
}
catch (Exception error)
{
return error;
}
} }
finally 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) private void ReleaseTeardownRecord(LiveEntityRecord record)
{ {
_projections.ReleaseTeardown(record); _projections.ReleaseTeardown(record);
@ -3125,7 +2871,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
private void CompleteSessionClearIfConverged() private void CompleteSessionClearIfConverged()
{ {
if (!_sessionClearPendingFinalization if (!_sessionClearPendingFinalization
|| !_directory.CompleteSessionClearIfConverged()) || !_entityObjects.CompleteSessionClearIfConverged())
{ {
return; return;
} }

View file

@ -80,8 +80,17 @@ public sealed class RuntimeEntityDirectory
var record = new RuntimeEntityRecord(snapshot); var record = new RuntimeEntityRecord(snapshot);
_activeByGuid.Add(snapshot.Guid, record); _activeByGuid.Add(snapshot.Guid, record);
try
{
ClaimLocalId(record);
return record; return record;
} }
catch
{
_activeByGuid.Remove(snapshot.Guid);
throw;
}
}
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
_activeByGuid.Remove(guid, out record); _activeByGuid.Remove(guid, out 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.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.Runtime.Entities; namespace AcDream.Runtime.Entities;
public readonly record struct RuntimeEntityRegistrationResult(
InboundCreateResult Inbound,
RuntimeEntityRecord? Canonical,
bool LogicalRegistrationCreated,
bool ReplacedExistingGeneration,
Exception? PriorGenerationCleanupFailure = null);
/// <summary> /// <summary>
/// One exact DeleteObject acceptance issued by a /// One exact DeleteObject acceptance issued by a
/// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the /// <see cref="RuntimeEntityObjectLifetime"/>. The graphical host retires the
@ -14,15 +22,21 @@ public sealed class RuntimeEntityDeleteAcceptance
{ {
internal RuntimeEntityDeleteAcceptance( internal RuntimeEntityDeleteAcceptance(
RuntimeEntityObjectLifetime owner, RuntimeEntityObjectLifetime owner,
DeleteObject.Parsed delete) DeleteObject.Parsed delete,
RuntimeEntityRecord? retiredCanonical,
bool removeRetainedObject)
{ {
Owner = owner; Owner = owner;
Delete = delete; Delete = delete;
RetiredCanonical = retiredCanonical;
RemoveRetainedObject = removeRetainedObject;
} }
internal RuntimeEntityObjectLifetime Owner { get; } internal RuntimeEntityObjectLifetime Owner { get; }
internal bool Completed { get; set; } internal bool Completed { get; set; }
public DeleteObject.Parsed Delete { get; } public DeleteObject.Parsed Delete { get; }
public RuntimeEntityRecord? RetiredCanonical { get; }
public bool RemoveRetainedObject { get; }
} }
/// <summary> /// <summary>
@ -30,17 +44,253 @@ public sealed class RuntimeEntityDeleteAcceptance
/// retained object lifetimes. Graphical and direct hosts borrow these exact /// retained object lifetimes. Graphical and direct hosts borrow these exact
/// instances; they never allocate a second directory or object table. /// instances; they never allocate a second directory or object table.
/// </summary> /// </summary>
public sealed class RuntimeEntityObjectLifetime public sealed class RuntimeEntityObjectLifetime : IDisposable
{ {
private bool _sessionClearInProgress;
private bool _disposed;
public RuntimeEntityObjectLifetime( public RuntimeEntityObjectLifetime(
uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId)
{ {
Entities = new RuntimeEntityDirectory(firstLocalEntityId); Entities = new RuntimeEntityDirectory(firstLocalEntityId);
Objects = new ClientObjectTable(); 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 RuntimeEntityDirectory Entities { get; }
public ClientObjectTable Objects { 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> /// <summary>
/// Applies the retained-object half of an accepted CreateObject while the /// Applies the retained-object half of an accepted CreateObject while the
@ -54,6 +304,7 @@ public sealed class RuntimeEntityObjectLifetime
WorldSession.EntitySpawn spawn, WorldSession.EntitySpawn spawn,
bool replaceGeneration) bool replaceGeneration)
{ {
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(canonical);
if (canonical.ServerGuid != spawn.Guid if (canonical.ServerGuid != spawn.Guid
|| canonical.Incarnation != spawn.InstanceSequence) || canonical.Incarnation != spawn.InstanceSequence)
@ -75,24 +326,423 @@ public sealed class RuntimeEntityObjectLifetime
&& IsCurrent(); && 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> /// <summary>
/// Accepts the exact DeleteObject generation without yet publishing the /// Accepts and retires the exact canonical DeleteObject generation without
/// retained-object removal. This split lets the graphical host retire the /// yet publishing the retained-object removal. This split lets a graphical
/// active identity first, matching the established reentrant callback /// host tear down the exact retired projection before completing the
/// order, while Runtime remains the only freshness authority. /// retained-object mutation while Runtime remains the only freshness,
/// identity, and canonical-lifetime authority.
/// </summary> /// </summary>
public bool TryAcceptDelete( public bool TryAcceptDelete(
DeleteObject.Parsed delete, DeleteObject.Parsed delete,
bool isLocalPlayer, bool isLocalPlayer,
bool removeRetainedObject,
out RuntimeEntityDeleteAcceptance acceptance) out RuntimeEntityDeleteAcceptance acceptance)
{ {
EnsureNotDisposed();
if (!Entities.TryDelete(delete, isLocalPlayer)) if (!Entities.TryDelete(delete, isLocalPlayer))
{ {
acceptance = null!; acceptance = null!;
return false; 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; return true;
} }
@ -104,6 +754,7 @@ public sealed class RuntimeEntityObjectLifetime
public void CompleteAcceptedDelete( public void CompleteAcceptedDelete(
RuntimeEntityDeleteAcceptance acceptance) RuntimeEntityDeleteAcceptance acceptance)
{ {
EnsureNotDisposed();
ArgumentNullException.ThrowIfNull(acceptance); ArgumentNullException.ThrowIfNull(acceptance);
if (!ReferenceEquals(acceptance.Owner, this)) if (!ReferenceEquals(acceptance.Owner, this))
{ {
@ -117,6 +768,7 @@ public sealed class RuntimeEntityObjectLifetime
} }
acceptance.Completed = true; acceptance.Completed = true;
if (acceptance.RemoveRetainedObject)
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete); ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
} }
@ -124,12 +776,124 @@ public sealed class RuntimeEntityObjectLifetime
/// Applies a delete accepted by the dormant exact-incarnation owner after /// Applies a delete accepted by the dormant exact-incarnation owner after
/// the active Runtime directory correctly reports no live record. /// the active Runtime directory correctly reports no live record.
/// </summary> /// </summary>
public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete) => public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete)
{
EnsureNotDisposed();
ObjectTableWiring.ApplyEntityDelete(Objects, delete); ObjectTableWiring.ApplyEntityDelete(Objects, delete);
}
/// <summary> /// <summary>
/// Clears retained object state at the Runtime-owned reset stage. App /// Clears retained object state at the Runtime-owned reset stage. App
/// projection teardown remains a later acknowledged stage. /// projection teardown remains a later acknowledged stage.
/// </summary> /// </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> /// <summary>Monotonic sequence owner for one runtime instance.</summary>
public sealed class RuntimeEventSequencer public sealed class RuntimeEventSequencer
{ {
private RuntimeGenerationToken _generation;
private bool _hasGeneration;
private ulong _sequence; private ulong _sequence;
public RuntimeEventStamp Next( public RuntimeEventStamp Next(
RuntimeGenerationToken generation, RuntimeGenerationToken generation,
ulong frameNumber) => ulong frameNumber)
new(generation, checked(++_sequence), frameNumber); {
if (!_hasGeneration || generation != _generation)
{
_generation = generation;
_sequence = 0;
_hasGeneration = true;
}
return new RuntimeEventStamp(
generation,
checked(++_sequence),
frameNumber);
}
public ulong LastSequence => _sequence; public ulong LastSequence => _sequence;
} }

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Input;
@ -174,6 +175,172 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State); 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 private sealed class Harness : IDisposable
{ {
public const uint PlayerGuid = 0x50000002u; public const uint PlayerGuid = 0x50000002u;
@ -185,10 +352,12 @@ public sealed class CurrentGameRuntimeAdapterTests
public Harness() public Harness()
{ {
Identity = new LocalPlayerIdentityState(); Identity = new LocalPlayerIdentityState();
Entities = LiveEntityRuntimeFixture.Create( EntityObjects = new RuntimeEntityObjectLifetime();
Entities = new LiveEntityRuntime(
new GpuWorldState(), new GpuWorldState(),
new NoopEntityResources()); new NoopEntityResources(),
Objects = new ClientObjectTable(); EntityObjects);
Objects = EntityObjects.Objects;
Chat = new ChatLog(); Chat = new ChatLog();
Selection = new SelectionState(); Selection = new SelectionState();
MovementInput = new DispatcherMovementInputSource(); MovementInput = new DispatcherMovementInputSource();
@ -233,7 +402,7 @@ public sealed class CurrentGameRuntimeAdapterTests
Commands, Commands,
Identity, Identity,
Entities, Entities,
Objects, EntityObjects,
Chat, Chat,
new LocalPlayerControllerSlot(), new LocalPlayerControllerSlot(),
WorldReveal, WorldReveal,
@ -246,6 +415,7 @@ public sealed class CurrentGameRuntimeAdapterTests
public RuntimeOptions Options { get; } public RuntimeOptions Options { get; }
public LocalPlayerIdentityState Identity { get; } public LocalPlayerIdentityState Identity { get; }
public RuntimeEntityObjectLifetime EntityObjects { get; }
public LiveEntityRuntime Entities { get; } public LiveEntityRuntime Entities { get; }
public ClientObjectTable Objects { get; } public ClientObjectTable Objects { get; }
public ChatLog Chat { get; } public ChatLog Chat { get; }
@ -541,6 +711,64 @@ public sealed class CurrentGameRuntimeAdapterTests
ActivationType activation) => false; 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 private sealed class SelectionQuery(uint target) : IWorldSelectionQuery
{ {
public uint? PickAtCursor(bool includeSelf) => target; public uint? PickAtCursor(bool includeSelf) => target;

View file

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

View file

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

View file

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

View file

@ -4,8 +4,11 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Runtime;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Runtime;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
namespace AcDream.App.Tests.World; namespace AcDream.App.Tests.World;
@ -230,6 +233,46 @@ public sealed class RuntimeEntityOwnershipTests
property => IsPresentationType(property.PropertyType)); 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) private static bool IsPresentationType(Type type)
{ {
string? ns = type.Namespace; string? ns = type.Namespace;

View file

@ -1,6 +1,7 @@
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Items;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
namespace AcDream.Runtime.Tests.Entities; namespace AcDream.Runtime.Tests.Entities;
@ -120,14 +121,19 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.False(lifetime.TryAcceptDelete( Assert.False(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, 7), new DeleteObject.Parsed(spawn.Guid, 7),
isLocalPlayer: false, isLocalPlayer: false,
removeRetainedObject: true,
out _)); out _));
Assert.NotNull(lifetime.Objects.Get(spawn.Guid)); Assert.NotNull(lifetime.Objects.Get(spawn.Guid));
Assert.True(lifetime.TryAcceptDelete( Assert.True(lifetime.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, 6), new DeleteObject.Parsed(spawn.Guid, 6),
isLocalPlayer: false, isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance accepted)); out RuntimeEntityDeleteAcceptance accepted));
Assert.Same(canonical, accepted.RetiredCanonical);
Assert.False(lifetime.Entities.TryGetActive(spawn.Guid, out _));
lifetime.CompleteAcceptedDelete(accepted); lifetime.CompleteAcceptedDelete(accepted);
Assert.Null(lifetime.RetireCanonicalOnly(canonical));
Assert.Null(lifetime.Objects.Get(spawn.Guid)); Assert.Null(lifetime.Objects.Get(spawn.Guid));
} }
@ -146,6 +152,7 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.True(owner.TryAcceptDelete( Assert.True(owner.TryAcceptDelete(
new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence),
isLocalPlayer: false, isLocalPlayer: false,
removeRetainedObject: true,
out RuntimeEntityDeleteAcceptance acceptance)); out RuntimeEntityDeleteAcceptance acceptance));
Assert.Throws<InvalidOperationException>( Assert.Throws<InvalidOperationException>(
@ -175,6 +182,509 @@ public sealed class RuntimeEntityObjectLifetimeTests
Assert.Same(canonical, retained); 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( private static RuntimeEntityRecord Accept(
RuntimeEntityObjectLifetime lifetime, RuntimeEntityObjectLifetime lifetime,
WorldSession.EntitySpawn spawn) WorldSession.EntitySpawn spawn)
@ -248,4 +758,38 @@ public sealed class RuntimeEntityObjectLifetimeTests
PositionSequence: 1, PositionSequence: 1,
Physics: physics); 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); 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] [Fact]
public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners() public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners()
{ {