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:
parent
d3e96ff912
commit
ce3ac310d9
23 changed files with 2352 additions and 666 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -1590,6 +1590,7 @@ public sealed class GameWindow :
|
||||||
_streamer,
|
_streamer,
|
||||||
_equippedChildRenderer,
|
_equippedChildRenderer,
|
||||||
_liveEntities,
|
_liveEntities,
|
||||||
|
_runtimeEntityObjects,
|
||||||
_renderSceneShadow,
|
_renderSceneShadow,
|
||||||
_livePresentationBindings,
|
_livePresentationBindings,
|
||||||
_entityEffectAdvance,
|
_entityEffectAdvance,
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,7 +103,16 @@ internal sealed class CurrentGameRuntimeEventAdapter
|
||||||
primaryObjectId,
|
primaryObjectId,
|
||||||
text);
|
text);
|
||||||
foreach (IRuntimeEventObserver observer in observers)
|
foreach (IRuntimeEventObserver observer in observers)
|
||||||
observer.OnCommand(in delta);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
observer.OnCommand(in delta);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
RecordDispatchFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void EmitLifecycle(RuntimeLifecycleState previous)
|
public void EmitLifecycle(RuntimeLifecycleState previous)
|
||||||
|
|
@ -123,7 +129,16 @@ internal sealed class CurrentGameRuntimeEventAdapter
|
||||||
previous,
|
previous,
|
||||||
current);
|
current);
|
||||||
foreach (IRuntimeEventObserver observer in observers)
|
foreach (IRuntimeEventObserver observer in observers)
|
||||||
observer.OnLifecycle(in delta);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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)
|
||||||
observer.OnEntity(in delta);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
observer.OnEntity(in delta);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
{
|
||||||
|
RecordDispatchFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnObjectAdded(ClientObject item) =>
|
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||||
EmitInventory(RuntimeInventoryChange.Added, item);
|
|
||||||
|
|
||||||
private void OnObjectUpdated(ClientObject item) =>
|
|
||||||
EmitInventory(RuntimeInventoryChange.Updated, item);
|
|
||||||
|
|
||||||
private void OnObjectMoved(ClientObjectMove move)
|
|
||||||
{
|
|
||||||
ClientObject? item = move.Item ?? _objects.Get(move.ItemId);
|
|
||||||
if (item is not null)
|
|
||||||
EmitInventory(RuntimeInventoryChange.Moved, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnObjectRemoved(ClientObjectRemoval removal) =>
|
|
||||||
EmitInventory(
|
|
||||||
RuntimeInventoryChange.Removed,
|
|
||||||
removal.Object,
|
|
||||||
removal.Generation);
|
|
||||||
|
|
||||||
private void OnObjectsCleared()
|
|
||||||
{
|
{
|
||||||
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)
|
||||||
observer.OnInventory(in delta);
|
{
|
||||||
}
|
try
|
||||||
|
{
|
||||||
private void EmitInventory(
|
observer.OnInventory(in delta);
|
||||||
RuntimeInventoryChange change,
|
}
|
||||||
ClientObject item,
|
catch (Exception error)
|
||||||
ushort? exactGeneration = null)
|
{
|
||||||
{
|
RecordDispatchFailure(error);
|
||||||
if (!TryObservers(out IRuntimeEventObserver[] observers))
|
}
|
||||||
return;
|
}
|
||||||
var delta = new RuntimeInventoryDelta(
|
|
||||||
NextStamp(),
|
|
||||||
change,
|
|
||||||
CurrentGameRuntimeViewAdapter.Snapshot(
|
|
||||||
item,
|
|
||||||
_entities,
|
|
||||||
exactGeneration));
|
|
||||||
foreach (IRuntimeEventObserver observer in observers)
|
|
||||||
observer.OnInventory(in delta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
observer.OnChat(in delta);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
return new LiveEntityRegistrationResult(
|
incoming,
|
||||||
result,
|
RetirePriorProjection);
|
||||||
Canonical: null,
|
RuntimeEntityRecord? canonical = registration.Canonical;
|
||||||
Projection: null,
|
LiveEntityRecord? projection = canonical is null
|
||||||
LogicalRegistrationCreated: false,
|
? null
|
||||||
ReplacedExistingGeneration: false);
|
: _projections.GetCurrentOrDefault(canonical.ServerGuid);
|
||||||
ulong sessionVersion = _directory.SessionLifetimeVersion;
|
if (projection is not null
|
||||||
ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid);
|
&& ReferenceEquals(projection.Canonical, canonical))
|
||||||
|
|
||||||
if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration)
|
|
||||||
{
|
{
|
||||||
if (_directory.TryGetActive(
|
RefreshSpatialRuntimeIndexes(projection);
|
||||||
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(
|
return new LiveEntityRegistrationResult(
|
||||||
result,
|
registration.Inbound,
|
||||||
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,
|
||||||
|
|
@ -1266,16 +1139,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
||||||
{
|
{
|
||||||
if (!retryingAcceptedDelete)
|
if (!retryingAcceptedDelete)
|
||||||
{
|
{
|
||||||
if (removeRetainedObject)
|
try
|
||||||
{
|
{
|
||||||
try
|
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
|
||||||
{
|
}
|
||||||
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
|
catch (Exception error)
|
||||||
}
|
{
|
||||||
catch (Exception error)
|
(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(
|
||||||
bool applied = _directory.TryApplyObjDesc(update, out accepted);
|
update,
|
||||||
if (applied
|
canonical =>
|
||||||
&& _directory.TryGetActive(
|
{
|
||||||
update.Guid,
|
if (_projections.TryGet(
|
||||||
out RuntimeEntityRecord canonical))
|
canonical,
|
||||||
{
|
out LiveEntityRecord? record))
|
||||||
_directory.RefreshSnapshot(canonical, accepted);
|
{
|
||||||
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
|
record.NoteObjDescProjectionSynchronization();
|
||||||
record.AdvanceObjDescAuthority();
|
}
|
||||||
else
|
},
|
||||||
_directory.AdvanceObjDescAuthority(canonical);
|
out accepted);
|
||||||
}
|
|
||||||
return applied;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
||||||
bool applied = _directory.TryApplyState(update, out accepted);
|
update,
|
||||||
transition = default;
|
(canonical, _) =>
|
||||||
if (applied
|
{
|
||||||
&& _directory.TryGetActive(
|
if (_projections.TryGet(
|
||||||
update.Guid,
|
canonical,
|
||||||
out RuntimeEntityRecord canonical))
|
out LiveEntityRecord? record))
|
||||||
{
|
{
|
||||||
_directory.RefreshSnapshot(canonical, accepted);
|
RefreshPresentation(record);
|
||||||
transition = _directory.ApplyRawPhysicsState(
|
}
|
||||||
canonical,
|
},
|
||||||
update.PhysicsState);
|
out accepted,
|
||||||
if (_projections.TryGet(canonical, out LiveEntityRecord? record))
|
out transition);
|
||||||
RefreshPresentation(record);
|
|
||||||
}
|
|
||||||
return applied;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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 =
|
||||||
update.Guid,
|
_directory.TryGetActive(
|
||||||
out RuntimeEntityRecord? beforeCanonical);
|
update.Guid,
|
||||||
LiveEntityRecord? beforePosition = null;
|
out RuntimeEntityRecord canonical)
|
||||||
if (hadCanonical)
|
&& (canonical.FullCellId == 0u
|
||||||
_projections.TryGet(beforeCanonical!, out beforePosition);
|
|| !_projections.TryGet(
|
||||||
bool wasCellLess = hadCanonical
|
canonical,
|
||||||
&& (beforeCanonical!.FullCellId == 0
|
out LiveEntityRecord? projection)
|
||||||
|| beforePosition is null
|
|| !projection.IsSpatiallyProjected
|
||||||
|| !beforePosition.IsSpatiallyProjected
|
|| !projection.IsSpatiallyVisible);
|
||||||
|| !beforePosition.IsSpatiallyVisible);
|
return _entityObjects.TryApplyPosition(
|
||||||
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.TryGetActive(guid, out RuntimeEntityRecord canonical))
|
if (!_directory.IsCurrent(canonical)
|
||||||
|
|| !_projections.TryGet(
|
||||||
|
canonical,
|
||||||
|
out LiveEntityRecord? record))
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
ClearWorldCell(canonical);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearWorldCell(RuntimeEntityRecord canonical)
|
SynchronizePhysicsBodyActiveState(record);
|
||||||
{
|
|
||||||
if (!_directory.IsCurrent(canonical))
|
|
||||||
return;
|
|
||||||
if (!_projections.TryGet(canonical, out LiveEntityRecord? record))
|
|
||||||
{
|
|
||||||
_directory.SetFullCell(canonical, 0, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bool wasOrdinaryRoot = record.ProjectionKey is { } key
|
|
||||||
&& _spatialRootObjects.TryGetValue(
|
|
||||||
key,
|
|
||||||
out LiveEntityRecord? indexedRoot)
|
|
||||||
&& ReferenceEquals(indexedRoot, record);
|
|
||||||
// Pickup/Parent is retail's leave-world edge. Suspend the canonical
|
|
||||||
// object clock while the record is still indexed as a root; clearing
|
|
||||||
// FullCellId first removes that evidence and makes the later projection
|
|
||||||
// withdrawal look like a duplicate non-root edge.
|
|
||||||
if (wasOrdinaryRoot)
|
|
||||||
{
|
|
||||||
record.SuspendObjectClock();
|
|
||||||
SynchronizePhysicsBodyActiveState(record);
|
|
||||||
}
|
|
||||||
_directory.SetFullCell(canonical, 0, 0);
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,16 @@ public sealed class RuntimeEntityDirectory
|
||||||
|
|
||||||
var record = new RuntimeEntityRecord(snapshot);
|
var record = new RuntimeEntityRecord(snapshot);
|
||||||
_activeByGuid.Add(snapshot.Guid, record);
|
_activeByGuid.Add(snapshot.Guid, record);
|
||||||
return record;
|
try
|
||||||
|
{
|
||||||
|
ClaimLocalId(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_activeByGuid.Remove(snapshot.Guid);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
|
public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) =>
|
||||||
|
|
|
||||||
271
src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs
Normal file
271
src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs
Normal 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. J4–J6 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,19 +768,132 @@ public sealed class RuntimeEntityObjectLifetime
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptance.Completed = true;
|
acceptance.Completed = true;
|
||||||
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
|
if (acceptance.RemoveRetainedObject)
|
||||||
|
ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs
Normal file
151
src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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\"",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue