diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 4e3fb595..3af08129 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -891,7 +891,7 @@ internal sealed class SessionPlayerCompositionPhase liveSessionCommands, d.PlayerIdentity, live.LiveEntities, - d.EntityObjects.Objects, + d.EntityObjects, d.Chat, d.PlayerController, worldReveal, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ea445e0c..040572e6 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1590,6 +1590,7 @@ public sealed class GameWindow : _streamer, _equippedChildRenderer, _liveEntities, + _runtimeEntityObjects, _renderSceneShadow, _livePresentationBindings, _entityEffectAdvance, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 84aa8021..03663c49 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -16,6 +16,7 @@ using AcDream.App.World; using AcDream.Content; using AcDream.Core.Audio; using AcDream.Core.Physics; +using AcDream.Runtime.Entities; using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Input; using DatReaderWriter; @@ -81,6 +82,7 @@ internal sealed record LiveShutdownRoots( LandblockStreamer? Streamer, EquippedChildRenderController? EquippedChildren, LiveEntityRuntime? LiveEntities, + RuntimeEntityObjectLifetime EntityObjects, RenderSceneShadowRuntime? RenderSceneShadow, LivePresentationRuntimeBindings? PresentationBindings, DeferredEntityEffectAdvanceSource EffectAdvance, @@ -384,6 +386,12 @@ internal static class GameWindowShutdownManifest "shadow render scene", () => live.RenderSceneShadow?.Dispose()), ]), + new ResourceShutdownStage("runtime entity/object stream", + [ + Hard( + "runtime entity/object lifetime", + live.EntityObjects.Dispose), + ]), new ResourceShutdownStage("effect dispatch edges", [ Hard("live-presentation bindings", () => live.PresentationBindings?.Dispose()), diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 9b10c541..4fa208d2 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -5,9 +5,9 @@ using AcDream.App.Net; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Core.Chat; -using AcDream.Core.Items; using AcDream.Core.Selection; using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; @@ -35,7 +35,7 @@ internal sealed class CurrentGameRuntimeAdapter ICommandBus commands, LocalPlayerIdentityState playerIdentity, LiveEntityRuntime entities, - ClientObjectTable objects, + RuntimeEntityObjectLifetime entityObjects, ChatLog chat, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, @@ -49,17 +49,18 @@ internal sealed class CurrentGameRuntimeAdapter session, playerIdentity, entities, - objects, + entityObjects, chat, playerController, worldReveal, clock); + entityObjects.BindEventContext( + () => _view.Generation, + () => clock.FrameNumber); _events = new CurrentGameRuntimeEventAdapter( _view, - entities, - objects, - chat, - clock); + entityObjects, + chat); _commands = new CurrentGameRuntimeCommandAdapter( session, sessionHost, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs index 2c76761a..de3070bb 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeEventAdapter.cs @@ -1,8 +1,6 @@ -using AcDream.App.World; using AcDream.Core.Chat; -using AcDream.Core.Items; -using AcDream.Core.Physics; using AcDream.Runtime; +using AcDream.Runtime.Entities; namespace AcDream.App.Runtime; @@ -26,31 +24,30 @@ internal interface ICurrentGameRuntimeEventSink internal sealed class CurrentGameRuntimeEventAdapter : IRuntimeEventSource, ICurrentGameRuntimeEventSink, + IRuntimeEntityObjectObserver, IDisposable { private readonly CurrentGameRuntimeViewAdapter _view; - private readonly LiveEntityRuntime _entities; - private readonly ClientObjectTable _objects; + private readonly RuntimeEntityObjectLifetime _entityObjects; private readonly ChatLog _chat; - private readonly IGameRuntimeClock _clock; - private readonly RuntimeEventSequencer _events = new(); private readonly object _observerGate = new(); private IRuntimeEventObserver[] _observers = []; + private IDisposable? _entityObjectSubscription; private bool _ownerEventsAttached; private bool _disposed; + internal long DispatchFailureCount { get; private set; } + internal Exception? LastDispatchFailure { get; private set; } + public CurrentGameRuntimeEventAdapter( CurrentGameRuntimeViewAdapter view, - LiveEntityRuntime entities, - ClientObjectTable objects, - ChatLog chat, - IGameRuntimeClock clock) + RuntimeEntityObjectLifetime entityObjects, + ChatLog chat) { _view = view ?? throw new ArgumentNullException(nameof(view)); - _entities = entities ?? throw new ArgumentNullException(nameof(entities)); - _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _entityObjects = entityObjects + ?? throw new ArgumentNullException(nameof(entityObjects)); _chat = chat ?? throw new ArgumentNullException(nameof(chat)); - _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } public IDisposable Subscribe(IRuntimeEventObserver observer) @@ -106,7 +103,16 @@ internal sealed class CurrentGameRuntimeEventAdapter primaryObjectId, text); foreach (IRuntimeEventObserver observer in observers) - observer.OnCommand(in delta); + { + try + { + observer.OnCommand(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } } public void EmitLifecycle(RuntimeLifecycleState previous) @@ -123,7 +129,16 @@ internal sealed class CurrentGameRuntimeEventAdapter previous, current); foreach (IRuntimeEventObserver observer in observers) - observer.OnLifecycle(in delta); + { + try + { + observer.OnLifecycle(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } } private void Unsubscribe(IRuntimeEventObserver observer) @@ -159,12 +174,7 @@ internal sealed class CurrentGameRuntimeEventAdapter private void AttachOwnerEvents() { - _entities.ProjectionVisibilityChanged += OnEntityVisibilityChanged; - _objects.ObjectAdded += OnObjectAdded; - _objects.ObjectUpdated += OnObjectUpdated; - _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemovalClassified += OnObjectRemoved; - _objects.Cleared += OnObjectsCleared; + _entityObjectSubscription = _entityObjects.Events.Subscribe(this); _chat.EntryAppended += OnChatEntry; _ownerEventsAttached = true; } @@ -174,79 +184,43 @@ internal sealed class CurrentGameRuntimeEventAdapter if (!_ownerEventsAttached) return; _chat.EntryAppended -= OnChatEntry; - _objects.Cleared -= OnObjectsCleared; - _objects.ObjectRemovalClassified -= OnObjectRemoved; - _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectUpdated -= OnObjectUpdated; - _objects.ObjectAdded -= OnObjectAdded; - _entities.ProjectionVisibilityChanged -= OnEntityVisibilityChanged; + _entityObjectSubscription?.Dispose(); + _entityObjectSubscription = null; _ownerEventsAttached = false; } - private void OnEntityVisibilityChanged(LiveEntityRecord record, bool visible) + public void OnEntity(in RuntimeEntityDelta delta) { if (!TryObservers(out IRuntimeEventObserver[] observers)) return; - RuntimeEntityChange change = visible - ? RuntimeEntityChange.Updated - : (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 - ? RuntimeEntityChange.Hidden - : RuntimeEntityChange.Withdrawn; - var delta = new RuntimeEntityDelta( - NextStamp(), - change, - CurrentGameRuntimeViewAdapter.Snapshot(record)); foreach (IRuntimeEventObserver observer in observers) - observer.OnEntity(in delta); + { + try + { + observer.OnEntity(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } } - private void OnObjectAdded(ClientObject item) => - EmitInventory(RuntimeInventoryChange.Added, item); - - private void OnObjectUpdated(ClientObject item) => - EmitInventory(RuntimeInventoryChange.Updated, item); - - private void OnObjectMoved(ClientObjectMove move) - { - ClientObject? item = move.Item ?? _objects.Get(move.ItemId); - if (item is not null) - EmitInventory(RuntimeInventoryChange.Moved, item); - } - - private void OnObjectRemoved(ClientObjectRemoval removal) => - EmitInventory( - RuntimeInventoryChange.Removed, - removal.Object, - removal.Generation); - - private void OnObjectsCleared() + public void OnInventory(in RuntimeInventoryDelta delta) { if (!TryObservers(out IRuntimeEventObserver[] observers)) return; - var delta = new RuntimeInventoryDelta( - NextStamp(), - RuntimeInventoryChange.Cleared, - default); foreach (IRuntimeEventObserver observer in observers) - observer.OnInventory(in delta); - } - - private void EmitInventory( - RuntimeInventoryChange change, - ClientObject item, - ushort? exactGeneration = null) - { - if (!TryObservers(out IRuntimeEventObserver[] observers)) - return; - var delta = new RuntimeInventoryDelta( - NextStamp(), - change, - CurrentGameRuntimeViewAdapter.Snapshot( - item, - _entities, - exactGeneration)); - foreach (IRuntimeEventObserver observer in observers) - observer.OnInventory(in delta); + { + try + { + observer.OnInventory(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } } private void OnChatEntry(ChatEntry entry) @@ -263,11 +237,20 @@ internal sealed class CurrentGameRuntimeEventAdapter entry.Text, entry.ChannelName)); foreach (IRuntimeEventObserver observer in observers) - observer.OnChat(in delta); + { + try + { + observer.OnChat(in delta); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } } private RuntimeEventStamp NextStamp() => - _events.Next(_view.Generation, _clock.FrameNumber); + _entityObjects.Events.NextStamp(); private bool TryObservers(out IRuntimeEventObserver[] observers) { @@ -275,6 +258,12 @@ internal sealed class CurrentGameRuntimeEventAdapter return observers.Length != 0; } + private void RecordDispatchFailure(Exception error) + { + DispatchFailureCount++; + LastDispatchFailure = error; + } + private sealed class ObserverSubscription( CurrentGameRuntimeEventAdapter owner, IRuntimeEventObserver observer) diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs index 43a3e9f7..19719fec 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeViewAdapter.cs @@ -5,6 +5,7 @@ using AcDream.App.World; using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Session; namespace AcDream.App.Runtime; @@ -21,8 +22,8 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView private readonly ClientObjectTable _objects; private readonly ChatLog _chat; private readonly IGameRuntimeClock _clock; - private readonly EntityView _entityView; - private readonly InventoryView _inventoryView; + private readonly IRuntimeEntityView _entityView; + private readonly IRuntimeInventoryView _inventoryView; private readonly ChatView _chatView; private readonly MovementView _movementView; private readonly PortalView _portalView; @@ -32,7 +33,7 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView LiveSessionController session, LocalPlayerIdentityState playerIdentity, LiveEntityRuntime entities, - ClientObjectTable objects, + RuntimeEntityObjectLifetime entityObjects, ChatLog chat, ILocalPlayerControllerSource playerController, WorldRevealCoordinator worldReveal, @@ -42,12 +43,13 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView _playerIdentity = playerIdentity ?? throw new ArgumentNullException(nameof(playerIdentity)); _entities = entities ?? throw new ArgumentNullException(nameof(entities)); - _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + ArgumentNullException.ThrowIfNull(entityObjects); + _objects = entityObjects.Objects; _chat = chat ?? throw new ArgumentNullException(nameof(chat)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - _entityView = new EntityView(_entities); - _inventoryView = new InventoryView(_objects, _entities); + _entityView = entityObjects.EntityView; + _inventoryView = entityObjects.InventoryView; _chatView = new ChatView(_chat); _movementView = new MovementView( playerController @@ -108,103 +110,6 @@ internal sealed class CurrentGameRuntimeViewAdapter : IGameRuntimeView internal void Deactivate() => _active = false; - internal static RuntimeEntitySnapshot Snapshot(LiveEntityRecord record) => - new( - new RuntimeEntityIdentity( - record.ServerGuid, - record.LocalEntityId ?? 0u, - record.Generation), - record.FullCellId, - (uint)record.FinalPhysicsState, - record.PhysicsBody?.CellPosition, - record.LocalEntityId.HasValue, - record.IsSpatiallyVisible, - record.InitialHydrationCompleted); - - internal static RuntimeInventoryItemSnapshot Snapshot( - ClientObject item, - LiveEntityRuntime entities, - ushort? exactGeneration = null) - { - ushort generation = exactGeneration - ?? (entities.TryGetRecord(item.ObjectId, out LiveEntityRecord record) - ? record.Generation - : (ushort)0); - return new RuntimeInventoryItemSnapshot( - item.ObjectId, - generation, - item.Name, - item.ContainerId, - item.ContainerSlot, - item.WielderId, - (uint)item.CurrentlyEquippedLocation, - item.StackSize, - item.Value); - } - - private sealed class EntityView(LiveEntityRuntime owner) - : IRuntimeEntityView - { - public int Count => owner.Count; - public int MaterializedCount => owner.MaterializedCount; - - public bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity) - { - if (owner.TryGetRecord(serverGuid, out LiveEntityRecord record)) - { - entity = Snapshot(record); - return true; - } - - entity = default; - return false; - } - - public void Visit(IRuntimeEntityVisitor visitor) - { - ArgumentNullException.ThrowIfNull(visitor); - foreach (LiveEntityRecord record in owner.Records) - { - RuntimeEntitySnapshot entity = Snapshot(record); - visitor.Visit(in entity); - } - } - } - - private sealed class InventoryView( - ClientObjectTable owner, - LiveEntityRuntime entities) - : IRuntimeInventoryView - { - public int ObjectCount => owner.ObjectCount; - public int ContainerCount => owner.ContainerCount; - - public bool TryGet( - uint objectId, - out RuntimeInventoryItemSnapshot item) - { - ClientObject? found = owner.Get(objectId); - if (found is not null) - { - item = Snapshot(found, entities); - return true; - } - - item = default; - return false; - } - - public void Visit(IRuntimeInventoryVisitor visitor) - { - ArgumentNullException.ThrowIfNull(visitor); - foreach (ClientObject item in owner.Objects) - { - RuntimeInventoryItemSnapshot snapshot = Snapshot(item, entities); - visitor.Visit(in snapshot); - } - } - } - private sealed class ChatView(ChatLog owner) : IRuntimeChatView { public long Revision => owner.Revision; diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 4da5f3fc..5abcb4ab 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime; using AcDream.Runtime.Entities; using System.Numerics; using System.Runtime.ExceptionServices; @@ -377,29 +378,13 @@ public sealed class LiveEntityRecord internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) => _directory.ApplyRawPhysicsState(Canonical, rawState); - internal void AdvancePositionAuthority() => - _directory.AdvancePositionAuthority(Canonical); - - internal void AdvanceVectorAuthority() => - _directory.AdvanceVectorAuthority(Canonical); - - internal void AdvanceMovementAuthority() => - _directory.AdvanceMovementAuthority(Canonical); - - internal void AdvanceObjDescAuthority() + internal void NoteObjDescProjectionSynchronization() { - _directory.AdvanceObjDescAuthority(Canonical); AppearanceProjectionSynchronizationPending = true; if (AppearanceHydrationInProgress) AppearanceHydrationRetryRequested = true; } - internal void AdvanceCreateAuthority() => - _directory.AdvanceCreateAuthority(Canonical); - - internal void SetChildNoDraw(bool noDraw) => - _directory.SetChildNoDraw(Canonical, noDraw); - /// /// Cell used when a streamed landblock asks live objects to restore their /// spatial projection. Once materialized, the canonical runtime cell wins; @@ -582,150 +567,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource : "A live entity cannot register from inside atomic resource registration."); } - InboundCreateResult result = _directory.AcceptCreate(incoming); - if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) - return new LiveEntityRegistrationResult( - result, - Canonical: null, - Projection: null, - LogicalRegistrationCreated: false, - ReplacedExistingGeneration: false); - ulong sessionVersion = _directory.SessionLifetimeVersion; - ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid); - - if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration) + RuntimeEntityRegistrationResult registration = + _entityObjects.RegisterEntity( + incoming, + RetirePriorProjection); + RuntimeEntityRecord? canonical = registration.Canonical; + LiveEntityRecord? projection = canonical is null + ? null + : _projections.GetCurrentOrDefault(canonical.ServerGuid); + if (projection is not null + && ReferenceEquals(projection.Canonical, canonical)) { - if (_directory.TryGetActive( - incoming.Guid, - out RuntimeEntityRecord retainedCanonical)) - { - _directory.RefreshSnapshot( - retainedCanonical, - result.Snapshot, - refreshPosition: true); - _directory.AdvanceCreateAuthority(retainedCanonical); - _projections.TryGet( - retainedCanonical, - out LiveEntityRecord? retainedProjection); - if (retainedProjection is not null) - RefreshSpatialRuntimeIndexes(retainedProjection); - return new LiveEntityRegistrationResult( - result, - retainedCanonical, - retainedProjection, - LogicalRegistrationCreated: false, - ReplacedExistingGeneration: false); - } - - // A failed materialization rollback retains this exact incarnation - // as a teardown tombstone. Do not create a second active record - // with the same (GUID, INSTANCE_TS) while its partial resources are - // still converging; a later retransmit can recover after teardown. - if (_directory.TryGetTeardown( - incoming.Guid, - result.Snapshot.InstanceSequence, - out _)) - { - return new LiveEntityRegistrationResult( - result, - Canonical: null, - Projection: null, - false, - false); - } - - // Defensive repair for a caller that cleared only the logical map. - // Normal session teardown clears both owners together. - RuntimeEntityRecord recoveredCanonical = - _directory.AddActive(result.Snapshot); - return new LiveEntityRegistrationResult( - result, - recoveredCanonical, - Projection: null, - LogicalRegistrationCreated: true, - ReplacedExistingGeneration: false); + RefreshSpatialRuntimeIndexes(projection); } - - bool replaced = _directory.RemoveActive( - incoming.Guid, - out RuntimeEntityRecord? oldCanonical); - LiveEntityRecord? old = null; - if (oldCanonical is not null - && _projections.TryGet(oldCanonical, out LiveEntityRecord? projected)) - { - old = projected; - if (!_projections.RemoveActive(old)) - { - throw new InvalidOperationException( - $"Exact App projection for 0x{incoming.Guid:X8}/{old.Generation} could not be retired."); - } - } - if (result.Disposition is CreateObjectTimestampDisposition.NewGeneration) - ParentAttachments.EndGeneration(incoming.Guid, result.Snapshot.InstanceSequence); - - Exception? tearDownFailure = null; - if (old is not null) - { - RetainTeardownRecord(old); - _logicalTeardownDepth++; - try - { - try - { - TearDownRecord(old); - ReleaseTeardownRecord(old); - } - catch (Exception error) - { - tearDownFailure = error; - } - } - finally - { - _logicalTeardownDepth--; - } - } - else if (oldCanonical is not null) - { - TearDownCanonicalOnly(oldCanonical); - } - - // Resource callbacks are arbitrary App integration code. Any nested - // create, accepted delete, or session reset advances this epoch. The - // outer packet is then superseded even when the nested action left no - // record/snapshot (delete/reset); reinstalling it would resurrect an - // incarnation after an accepted terminal event. - if (_directory.SessionLifetimeVersion != sessionVersion - || CurrentLifetimeMutation(incoming.Guid) != operationVersion) - { - if (tearDownFailure is not null) - { - throw new AggregateException( - $"Prior incarnation of live entity 0x{incoming.Guid:X8} failed teardown while its incoming replacement was superseded.", - tearDownFailure); - } - - return new LiveEntityRegistrationResult( - SupersededCreateResult(), - _directory.TryGetActive( - incoming.Guid, - out RuntimeEntityRecord currentCanonical) - ? currentCanonical - : null, - _projections.GetCurrentOrDefault(incoming.Guid), - false, - replaced, - tearDownFailure); - } - - RuntimeEntityRecord canonical = _directory.AddActive(result.Snapshot); return new LiveEntityRegistrationResult( - result, + registration.Inbound, canonical, - Projection: null, - true, - replaced, - tearDownFailure); + projection, + registration.LogicalRegistrationCreated, + registration.ReplacedExistingGeneration, + registration.PriorGenerationCleanupFailure); } /// @@ -785,7 +646,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool createdSidecar = false; if (!_projections.TryGet(expectedCanonical, out record)) { - uint localId = _directory.ClaimLocalId(expectedCanonical); + _ = expectedCanonical.LocalEntityId + ?? throw new InvalidOperationException( + "Runtime must issue entity identity before App projection."); try { record = _projections.AddMaterializing(expectedCanonical); @@ -800,7 +663,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource record.ProjectionKey = null; record = null; } - _directory.ReleaseLocalId(expectedCanonical); throw; } } @@ -833,7 +695,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { _projections.RemoveActive(record); record.ProjectionKey = null; - _directory.ReleaseLocalId(expectedCanonical); record = null; } throw; @@ -870,14 +731,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { _projections.RemoveActive(record); record.ProjectionKey = null; - _directory.ReleaseLocalId(expectedCanonical); record.WorldEntity = null; record = null; throw; } - if (_directory.IsCurrent(expectedCanonical)) - _directory.RemoveActive(expectedCanonical); + _entityObjects.RetireAfterProjectionAcquisitionFailure( + expectedCanonical); if (_projections.RemoveActive(record)) { RetainTeardownRecord(record); @@ -983,11 +843,24 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool visible = _spatial.IsLiveEntityProjectionResident(key); record.IsSpatiallyVisible = visible; RefreshPresentation(record); - if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) - record.FullCellId = spatialCellOrLandblockId; - record.CanonicalLandblockId = spatialCellOrLandblockId == 0 + uint committedFullCell = + (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu + ? spatialCellOrLandblockId + : record.FullCellId; + uint committedLandblock = spatialCellOrLandblockId == 0 ? 0u : (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + if (!_entityObjects.CommitRebucket( + record.Canonical, + committedFullCell, + committedLandblock)) + { + ThrowAfterCommittedProjectionChange( + serverGuid, + spatialNotificationFailure, + runtimeNotificationFailure: null); + return false; + } bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World && record.IsSpatiallyProjected && record.IsSpatiallyVisible @@ -1182,7 +1055,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return false; } - ClearWorldCell(serverGuid); + if (!_entityObjects.CommitWithdrawal( + record.Canonical, + AcknowledgeCelllessCanonicalCommit)) + { + return false; + } record.WorldEntity.ParentCellId = 0u; return WithdrawLiveEntityProjection(serverGuid); } @@ -1218,10 +1096,9 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource if (!_entityObjects.TryAcceptDelete( delete, isLocalPlayer, + removeRetainedObject, out acceptedDelete)) return false; - AdvanceLifetimeMutation(delete.Guid); - ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); // End active gameplay identity before arbitrary callbacks so a // newer generation can be accepted re-entrantly, but retain the @@ -1229,11 +1106,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource // succeeded. A throwing resource callback can then be retried by // the same accepted Delete instead of orphaning its owner. LiveEntityRecord? retainedRegistrationFailure = record; - if (_directory.TryGetActive( - delete.Guid, - out RuntimeEntityRecord activeCanonical) - && activeCanonical.Incarnation == delete.InstanceSequence - && _directory.RemoveActive(activeCanonical)) + if (acceptedDelete.RetiredCanonical is { } activeCanonical) { if (_projections.TryGet( activeCanonical, @@ -1266,16 +1139,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { if (!retryingAcceptedDelete) { - if (removeRetainedObject) + try { - try - { - _entityObjects.CompleteAcceptedDelete(acceptedDelete!); - } - catch (Exception error) - { - (failures ??= new List()).Add(error); - } + _entityObjects.CompleteAcceptedDelete(acceptedDelete!); + } + catch (Exception error) + { + (failures ??= new List()).Add(error); } try @@ -1872,95 +1742,44 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _directory.TryGetSnapshot(guid, out spawn); public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) - { - bool applied = _directory.TryApplyObjDesc(update, out accepted); - if (applied - && _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvanceObjDescAuthority(); - else - _directory.AdvanceObjDescAuthority(canonical); - } - return applied; - } + => _entityObjects.TryApplyObjDesc( + update, + canonical => + { + if (_projections.TryGet( + canonical, + out LiveEntityRecord? record)) + { + record.NoteObjDescProjectionSynchronization(); + } + }, + out accepted); public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted) - { - bool applied = _directory.TryApplyPickup(update, out accepted); - if (applied - && _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvancePositionAuthority(); - else - _directory.AdvancePositionAuthority(canonical); - ClearWorldCell(canonical); - ParentAttachments.EndChildProjection(update.Guid); - } - return applied; - } + => _entityObjects.TryApplyPickup( + update, + AcknowledgeCelllessCanonicalCommit, + out accepted); public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted) - { - bool applied = _directory.TryApplyCreateParent(update, out accepted); - if (applied - && _directory.TryGetActive( - update.ChildGuid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvancePositionAuthority(); - else - _directory.AdvancePositionAuthority(canonical); - } - return applied; - } + => _entityObjects.TryApplyCreateParent( + update, + acknowledgeProjection: null, + out accepted); public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) - { - bool applied = _directory.TryApplyParent(update, out accepted); - if (applied - && _directory.TryGetActive( - update.ChildGuid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvancePositionAuthority(); - else - _directory.AdvancePositionAuthority(canonical); - } - return applied; - } + => _entityObjects.TryApplyParent( + update, + acknowledgeProjection: null, + out accepted); internal bool CommitStagedParent( ParentAttachmentRelation relation, out WorldSession.EntitySpawn accepted) - { - bool committed = _directory.TryCommitParent( - relation.ChildGuid, - relation.ParentGuid, - relation.ParentLocation, - relation.PlacementId, - relation.ChildPositionSequence, + => _entityObjects.TryCommitParent( + relation, + acknowledgeProjection: null, out accepted); - if (committed - && _directory.TryGetActive( - relation.ChildGuid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - } - return committed; - } /// /// Commits retail CPhysicsObj::set_parent's cell-less edge only @@ -1973,25 +1792,20 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource ulong positionAuthorityVersion) { ArgumentNullException.ThrowIfNull(record); - if (!IsCurrentPositionAuthority(record, positionAuthorityVersion)) - return false; - ClearWorldCell(record.ServerGuid); - return IsCurrentPositionAuthority(record, positionAuthorityVersion); + return _entityObjects.CommitAcceptedParentCellless( + record.Canonical, + positionAuthorityVersion, + AcknowledgeCelllessCanonicalCommit); } internal bool CommitAcceptedParentCellless( RuntimeEntityRecord canonical, ulong positionAuthorityVersion) { - ArgumentNullException.ThrowIfNull(canonical); - if (!_directory.IsCurrent(canonical) - || canonical.PositionAuthorityVersion != positionAuthorityVersion) - { - return false; - } - ClearWorldCell(canonical); - return _directory.IsCurrent(canonical) - && canonical.PositionAuthorityVersion == positionAuthorityVersion; + return _entityObjects.CommitAcceptedParentCellless( + canonical, + positionAuthorityVersion, + AcknowledgeCelllessCanonicalCommit); } public bool TryApplyMotion( @@ -1999,43 +1813,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource bool retainPayload, out WorldSession.EntitySpawn accepted, out AcceptedPhysicsTimestamps timestamps) - { - bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps); - if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot) - && _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, snapshot); - } - if (applied - && retainPayload - && _directory.TryGetActive(update.Guid, out canonical)) - { - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvanceMovementAuthority(); - else - _directory.AdvanceMovementAuthority(canonical); - } - return applied; - } + => _entityObjects.TryApplyMotion( + update, + retainPayload, + acknowledgeProjection: null, + out accepted, + out timestamps); public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) - { - bool applied = _directory.TryApplyVector(update, out accepted); - if (applied - && _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - record.AdvanceVectorAuthority(); - else - _directory.AdvanceVectorAuthority(canonical); - } - return applied; - } + => _entityObjects.TryApplyVector( + update, + acknowledgeProjection: null, + out accepted); public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted) => TryApplyState(update, out accepted, out _); @@ -2044,23 +1833,19 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource SetState.Parsed update, out WorldSession.EntitySpawn accepted, out RetailPhysicsStateTransition transition) - { - bool applied = _directory.TryApplyState(update, out accepted); - transition = default; - if (applied - && _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical)) - { - _directory.RefreshSnapshot(canonical, accepted); - transition = _directory.ApplyRawPhysicsState( - canonical, - update.PhysicsState); - if (_projections.TryGet(canonical, out LiveEntityRecord? record)) - RefreshPresentation(record); - } - return applied; - } + => _entityObjects.TryApplyState( + update, + (canonical, _) => + { + if (_projections.TryGet( + canonical, + out LiveEntityRecord? record)) + { + RefreshPresentation(record); + } + }, + out accepted, + out transition); /// /// Retail parent Hidden directly toggles each attached child's NoDraw bit @@ -2070,9 +1855,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { if (!_projections.TryGetCurrent(childServerGuid, out LiveEntityRecord? record)) return false; - record.SetChildNoDraw(noDraw); - RefreshPresentation(record); - return true; + return _entityObjects.CommitChildNoDraw( + record.Canonical, + noDraw, + _ => RefreshPresentation(record)); } public bool TryApplyPosition( @@ -2084,62 +1870,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource out WorldSession.EntitySpawn accepted, out AcceptedPhysicsTimestamps timestamps) { - bool hadCanonical = _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord? beforeCanonical); - LiveEntityRecord? beforePosition = null; - if (hadCanonical) - _projections.TryGet(beforeCanonical!, out beforePosition); - bool wasCellLess = hadCanonical - && (beforeCanonical!.FullCellId == 0 - || beforePosition is null - || !beforePosition.IsSpatiallyProjected - || !beforePosition.IsSpatiallyVisible); - bool known = _directory.TryApplyPosition( + bool projectionRequiresTeleportHook = + _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical) + && (canonical.FullCellId == 0u + || !_projections.TryGet( + canonical, + out LiveEntityRecord? projection) + || !projection.IsSpatiallyProjected + || !projection.IsSpatiallyVisible); + return _entityObjects.TryApplyPosition( update, isLocalPlayer, forcePositionRotation, currentLocalVelocity, + projectionRequiresTeleportHook, + acknowledgeProjection: null, out disposition, out accepted, out timestamps); - if (known && _directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) - { - bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; - if (disposition is PositionTimestampDisposition.Apply) - { - timestamps = timestamps with - { - TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess, - }; - } - if (_directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord acceptedCanonical)) - { - _directory.RefreshSnapshot( - acceptedCanonical, - snapshot, - refreshPosition: acceptedPosition); - if (acceptedPosition - && ReferenceEquals(acceptedCanonical, beforeCanonical)) - { - if (_projections.TryGet( - acceptedCanonical, - out LiveEntityRecord? acceptedRecord) - && ReferenceEquals(acceptedRecord, beforePosition)) - { - acceptedRecord.AdvancePositionAuthority(); - } - else - { - _directory.AdvancePositionAuthority(acceptedCanonical); - } - ParentAttachments.EndChildProjection(update.Guid); - } - } - } - return known; } public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) => @@ -2669,15 +2419,13 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _isClearing = true; _sessionClearPendingFinalization = true; - _directory.BeginSessionClear(); + IReadOnlyList retiredCanonicals = + _entityObjects.BeginSessionClear(); List? failures = null; try { - foreach (RuntimeEntityRecord canonical in - _directory.ActiveRecords.ToArray()) + foreach (RuntimeEntityRecord canonical in retiredCanonicals) { - if (!_directory.RemoveActive(canonical)) - continue; if (_projections.TryGet( canonical, out LiveEntityRecord? record)) @@ -2796,12 +2544,6 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _directory.RefreshSnapshot(canonical, accepted, refreshPosition); } - private static InboundCreateResult SupersededCreateResult() => new( - CreateObjectTimestampDisposition.StaleGeneration, - default, - null, - default); - private ulong AdvanceLifetimeMutation(uint serverGuid) => _directory.AdvanceLifetimeMutation(serverGuid); @@ -2841,37 +2583,18 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource ExceptionDispatchInfo.Capture(failure).Throw(); } - private void ClearWorldCell(uint guid) + private void AcknowledgeCelllessCanonicalCommit( + RuntimeEntityRecord canonical) { - if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)) + if (!_directory.IsCurrent(canonical) + || !_projections.TryGet( + canonical, + out LiveEntityRecord? record)) + { return; - ClearWorldCell(canonical); - } + } - private void ClearWorldCell(RuntimeEntityRecord canonical) - { - if (!_directory.IsCurrent(canonical)) - return; - if (!_projections.TryGet(canonical, out LiveEntityRecord? record)) - { - _directory.SetFullCell(canonical, 0, 0); - return; - } - bool wasOrdinaryRoot = record.ProjectionKey is { } key - && _spatialRootObjects.TryGetValue( - key, - out LiveEntityRecord? indexedRoot) - && ReferenceEquals(indexedRoot, record); - // Pickup/Parent is retail's leave-world edge. Suspend the canonical - // object clock while the record is still indexed as a root; clearing - // FullCellId first removes that evidence and makes the later projection - // withdrawal look like a duplicate non-root edge. - if (wasOrdinaryRoot) - { - record.SuspendObjectClock(); - SynchronizePhysicsBodyActiveState(record); - } - _directory.SetFullCell(canonical, 0, 0); + SynchronizePhysicsBodyActiveState(record); RefreshSpatialRuntimeIndexes(record); } @@ -3091,27 +2814,50 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource _projections.RetainTeardown(record); } - private void TearDownCanonicalOnly(RuntimeEntityRecord canonical) + private Exception? RetirePriorProjection( + RuntimeEntityRecord canonical) { - // A canonical-only incarnation has never crossed the App projection - // acquisition edge, so it owns no renderer/effect/animation suffix. - // Retain it briefly only so Runtime's validated mutators can converge - // any presentation-free physics state before the tombstone vanishes. - _directory.RetainTeardown(canonical); + if (!_projections.TryGet( + canonical, + out LiveEntityRecord? projection)) + { + return _entityObjects.RetireCanonicalOnly(canonical); + } + + if (!_projections.RemoveActive(projection)) + { + return new InvalidOperationException( + $"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired."); + } + + RetainTeardownRecord(projection); + _logicalTeardownDepth++; try { - _directory.SetPhysicsHost(canonical, null); - _directory.SetPhysicsBody(canonical, null); - _directory.SetPhysicsBodyAcquisitionInProgress(canonical, false); - _directory.SetHasPartArray(canonical, false); - _directory.ReleaseLocalId(canonical); + try + { + TearDownRecord(projection); + ReleaseTeardownRecord(projection); + return null; + } + catch (Exception error) + { + return error; + } } finally { - _directory.ReleaseTeardown(canonical); + _logicalTeardownDepth--; } } + private void TearDownCanonicalOnly(RuntimeEntityRecord canonical) + { + Exception? failure = _entityObjects.RetireCanonicalOnly(canonical); + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } + private void ReleaseTeardownRecord(LiveEntityRecord record) { _projections.ReleaseTeardown(record); @@ -3125,7 +2871,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource private void CompleteSessionClearIfConverged() { if (!_sessionClearPendingFinalization - || !_directory.CompleteSessionClearIfConverged()) + || !_entityObjects.CompleteSessionClearIfConverged()) { return; } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index 41501a40..726d5c67 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -80,7 +80,16 @@ public sealed class RuntimeEntityDirectory var record = new RuntimeEntityRecord(snapshot); _activeByGuid.Add(snapshot.Guid, record); - return record; + try + { + ClaimLocalId(record); + return record; + } + catch + { + _activeByGuid.Remove(snapshot.Guid); + throw; + } } public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs new file mode 100644 index 00000000..a2c4ba2f --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectEventStream.cs @@ -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); +} + +/// +/// 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. +/// +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 _generation = static () => default; + private Func _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; } + + /// + /// 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. + /// + public void BindContext( + Func generation, + Func 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); + } + + /// + /// 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. + /// + 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); + } +} diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 825f51d4..9c4eb41f 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -1,9 +1,17 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; namespace AcDream.Runtime.Entities; +public readonly record struct RuntimeEntityRegistrationResult( + InboundCreateResult Inbound, + RuntimeEntityRecord? Canonical, + bool LogicalRegistrationCreated, + bool ReplacedExistingGeneration, + Exception? PriorGenerationCleanupFailure = null); + /// /// One exact DeleteObject acceptance issued by a /// . The graphical host retires the @@ -14,15 +22,21 @@ public sealed class RuntimeEntityDeleteAcceptance { internal RuntimeEntityDeleteAcceptance( RuntimeEntityObjectLifetime owner, - DeleteObject.Parsed delete) + DeleteObject.Parsed delete, + RuntimeEntityRecord? retiredCanonical, + bool removeRetainedObject) { Owner = owner; Delete = delete; + RetiredCanonical = retiredCanonical; + RemoveRetainedObject = removeRetainedObject; } internal RuntimeEntityObjectLifetime Owner { get; } internal bool Completed { get; set; } public DeleteObject.Parsed Delete { get; } + public RuntimeEntityRecord? RetiredCanonical { get; } + public bool RemoveRetainedObject { get; } } /// @@ -30,17 +44,253 @@ public sealed class RuntimeEntityDeleteAcceptance /// retained object lifetimes. Graphical and direct hosts borrow these exact /// instances; they never allocate a second directory or object table. /// -public sealed class RuntimeEntityObjectLifetime +public sealed class RuntimeEntityObjectLifetime : IDisposable { + private bool _sessionClearInProgress; + private bool _disposed; + public RuntimeEntityObjectLifetime( uint firstLocalEntityId = RuntimeEntityDirectory.FirstLocalEntityId) { Entities = new RuntimeEntityDirectory(firstLocalEntityId); Objects = new ClientObjectTable(); + var views = new RuntimeEntityObjectViews(Entities, Objects); + EntityView = views.Entities; + InventoryView = views.Inventory; + Events = new RuntimeEntityObjectEventStream(Entities, Objects); } public RuntimeEntityDirectory Entities { get; } public ClientObjectTable Objects { get; } + public IRuntimeEntityView EntityView { get; } + public IRuntimeInventoryView InventoryView { get; } + public RuntimeEntityObjectEventStream Events { get; } + + public void BindEventContext( + Func generation, + Func frameNumber) + { + EnsureNotDisposed(); + Events.BindContext(generation, frameNumber); + } + + /// + /// Owns the presentation-free half of retail's CreateObject lifetime + /// transaction. An attached graphical host may synchronously retire the + /// displaced projection through ; + /// a direct host omits it and Runtime retires canonical-only state. + /// + public RuntimeEntityRegistrationResult RegisterEntity( + WorldSession.EntitySpawn incoming, + Func? 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); + } + + /// + /// Completes a displaced incarnation that never crossed the graphical + /// projection acquisition edge. + /// + 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; + } + } /// /// Applies the retained-object half of an accepted CreateObject while the @@ -54,6 +304,7 @@ public sealed class RuntimeEntityObjectLifetime WorldSession.EntitySpawn spawn, bool replaceGeneration) { + EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(canonical); if (canonical.ServerGuid != spawn.Guid || canonical.Incarnation != spawn.InstanceSequence) @@ -75,24 +326,423 @@ public sealed class RuntimeEntityObjectLifetime && IsCurrent(); } + public bool TryApplyObjDesc( + ObjDescEvent.Parsed update, + Action? 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? 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? 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? 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? 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? 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? 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? 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? + 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? 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? 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? 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? 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; + } + /// - /// Accepts the exact DeleteObject generation without yet publishing the - /// retained-object removal. This split lets the graphical host retire the - /// active identity first, matching the established reentrant callback - /// order, while Runtime remains the only freshness authority. + /// Accepts and retires the exact canonical DeleteObject generation without + /// yet publishing the retained-object removal. This split lets a graphical + /// host tear down the exact retired projection before completing the + /// retained-object mutation while Runtime remains the only freshness, + /// identity, and canonical-lifetime authority. /// public bool TryAcceptDelete( DeleteObject.Parsed delete, bool isLocalPlayer, + bool removeRetainedObject, out RuntimeEntityDeleteAcceptance acceptance) { + EnsureNotDisposed(); if (!Entities.TryDelete(delete, isLocalPlayer)) { acceptance = null!; return false; } - acceptance = new RuntimeEntityDeleteAcceptance(this, delete); + Entities.AdvanceLifetimeMutation(delete.Guid); + Entities.ParentAttachments.DeleteGeneration( + delete.Guid, + delete.InstanceSequence); + + RuntimeEntityRecord? retiredCanonical = null; + if (Entities.TryGetActive( + delete.Guid, + out RuntimeEntityRecord active) + && active.Incarnation == delete.InstanceSequence + && Entities.RemoveActive(active)) + { + retiredCanonical = active; + PublishEntity( + removeRetainedObject + ? RuntimeEntityChange.Deleted + : RuntimeEntityChange.Withdrawn, + active); + } + + acceptance = new RuntimeEntityDeleteAcceptance( + this, + delete, + retiredCanonical, + removeRetainedObject); return true; } @@ -104,6 +754,7 @@ public sealed class RuntimeEntityObjectLifetime public void CompleteAcceptedDelete( RuntimeEntityDeleteAcceptance acceptance) { + EnsureNotDisposed(); ArgumentNullException.ThrowIfNull(acceptance); if (!ReferenceEquals(acceptance.Owner, this)) { @@ -117,19 +768,132 @@ public sealed class RuntimeEntityObjectLifetime } acceptance.Completed = true; - ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete); + if (acceptance.RemoveRetainedObject) + ObjectTableWiring.ApplyEntityDelete(Objects, acceptance.Delete); } /// /// Applies a delete accepted by the dormant exact-incarnation owner after /// the active Runtime directory correctly reports no live record. /// - public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete) => + public void ApplyAcceptedDormantDelete(DeleteObject.Parsed delete) + { + EnsureNotDisposed(); ObjectTableWiring.ApplyEntityDelete(Objects, delete); + } /// /// Clears retained object state at the Runtime-owned reset stage. App /// projection teardown remains a later acknowledged stage. /// - public void ClearObjects() => Objects.Clear(); + public void ClearObjects() + { + EnsureNotDisposed(); + Objects.Clear(); + } + + public IReadOnlyList BeginSessionClear() + { + EnsureNotDisposed(); + if (_sessionClearInProgress) + return Array.Empty(); + + _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? 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); } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs new file mode 100644 index 00000000..f6e9b2cc --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectViews.cs @@ -0,0 +1,151 @@ +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using System.Numerics; + +namespace AcDream.Runtime.Entities; + +/// +/// Allocation-free borrowed views over the canonical entity/object lifetime. +/// These views contain no App projection or backend state. +/// +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); + } + } + } +} diff --git a/src/AcDream.Runtime/GameRuntimeEvents.cs b/src/AcDream.Runtime/GameRuntimeEvents.cs index 6287751f..283860b9 100644 --- a/src/AcDream.Runtime/GameRuntimeEvents.cs +++ b/src/AcDream.Runtime/GameRuntimeEvents.cs @@ -225,12 +225,26 @@ public sealed class RuntimeTraceRecorder : IRuntimeEventObserver /// Monotonic sequence owner for one runtime instance. public sealed class RuntimeEventSequencer { + private RuntimeGenerationToken _generation; + private bool _hasGeneration; private ulong _sequence; public RuntimeEventStamp Next( RuntimeGenerationToken generation, - ulong frameNumber) => - new(generation, checked(++_sequence), frameNumber); + ulong frameNumber) + { + if (!_hasGeneration || generation != _generation) + { + _generation = generation; + _sequence = 0; + _hasGeneration = true; + } + + return new RuntimeEventStamp( + generation, + checked(++_sequence), + frameNumber); + } public ulong LastSequence => _sequence; } diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index 7a30e0e0..3fb88318 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -11,10 +11,7 @@ public readonly record struct RuntimeEntitySnapshot( RuntimeEntityIdentity Identity, uint CellId, uint PhysicsState, - Position? Position, - bool IsMaterialized, - bool IsSpatiallyVisible, - bool IsHydrated); + Position? Position); public interface IRuntimeEntityVisitor { @@ -25,8 +22,6 @@ public interface IRuntimeEntityView { int Count { get; } - int MaterializedCount { get; } - bool TryGet(uint serverGuid, out RuntimeEntitySnapshot entity); /// diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs index bd233ab7..868489a0 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -251,7 +251,7 @@ public sealed class LiveEntityInboundAuthorityGateTests Assert.True(timestampAccepted); Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord replacement)); Assert.Equal((ushort)7, replacement.Incarnation); - Assert.Null(replacement.LocalEntityId); + Assert.NotNull(replacement.LocalEntityId); Assert.False(runtime.TryGetRecord(Guid, out _)); } diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index d7480c34..50c90d35 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -469,6 +469,7 @@ public sealed class GameWindowSlice8BoundaryTests "new ResourceShutdownStage(\"frame borrowers\"", "new ResourceShutdownStage(\"session dependents\"", "new ResourceShutdownStage(\"live entities\"", + "new ResourceShutdownStage(\"runtime entity/object stream\"", "new ResourceShutdownStage(\"effect dispatch edges\"", "new ResourceShutdownStage(\"live entity dependents\"", "new ResourceShutdownStage(\"submitted GPU work\"", diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 7090c14f..c7212c25 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -17,6 +17,7 @@ using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.World; using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Input; @@ -174,6 +175,172 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(RuntimeLifecycleState.Stopped, harness.Runtime.Lifecycle.State); } + [Fact] + public void DirectAndGraphicalHosts_ProduceIdenticalEntityObjectTrace() + { + WorldSession.EntitySpawn spawn = + Spawn(Harness.TargetGuid, instance: 7, cell: 0x12340001u); + var direct = new RuntimeEntityObjectLifetime(); + direct.BindEventContext(static () => default, static () => 0UL); + var directTrace = new EntityObjectTrace(); + using IDisposable directSubscription = + direct.Events.Subscribe(directTrace); + + RuntimeEntityRegistrationResult directRegistration = + direct.RegisterEntity(spawn); + RuntimeEntityRecord directCanonical = + Assert.IsType(directRegistration.Canonical); + direct.Objects.AddOrUpdate(Object(spawn)); + direct.Objects.MoveItem( + spawn.Guid, + Harness.PlayerGuid, + newSlot: 3); + var vector = new VectorUpdate.Parsed( + spawn.Guid, + new Vector3(1f, 2f, 3f), + new Vector3(0f, 0f, 0.25f), + spawn.InstanceSequence, + VectorSequence: 2); + Assert.True(direct.TryApplyVector( + vector, + acknowledgeProjection: null, + out _)); + var state = new SetState.Parsed( + spawn.Guid, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Hidden), + spawn.InstanceSequence, + StateSequence: 2); + Assert.True(direct.TryApplyState( + state, + acknowledgeProjection: null, + out _, + out _)); + WorldSession.EntityPositionUpdate position = + Position(spawn.Guid, spawn.InstanceSequence); + Assert.True(direct.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + projectionRequiresTeleportHook: false, + acknowledgeProjection: null, + out _, + out _, + out _)); + Assert.True(direct.TryApplyPickup( + new PickupEvent.Parsed( + spawn.Guid, + spawn.InstanceSequence, + PositionSequence: 3), + acknowledgeProjection: null, + out _)); + Assert.True(direct.TryAcceptDelete( + new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance directDelete)); + direct.CompleteAcceptedDelete(directDelete); + Assert.Null(direct.RetireCanonicalOnly(directCanonical)); + + using var graphical = new Harness(); + var graphicalTrace = new EntityObjectTrace(); + using IDisposable graphicalSubscription = + graphical.EntityObjects.Events.Subscribe(graphicalTrace); + _ = graphical.Entities.RegisterAndMaterializeProjection(spawn); + graphical.Objects.AddOrUpdate(Object(spawn)); + graphical.Objects.MoveItem( + spawn.Guid, + Harness.PlayerGuid, + newSlot: 3); + Assert.True(graphical.Entities.TryApplyVector(vector, out _)); + Assert.True(graphical.Entities.TryApplyState(state, out _, out _)); + Assert.True(graphical.Entities.TryApplyPosition( + position, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + Assert.True(graphical.Entities.TryApplyPickup( + new PickupEvent.Parsed( + spawn.Guid, + spawn.InstanceSequence, + PositionSequence: 3), + out _)); + Assert.True(graphical.Entities.UnregisterLiveEntity( + new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), + isLocalPlayer: false, + removeRetainedObject: true)); + + Assert.Equal(directTrace.Entries, graphicalTrace.Entries); + Assert.Equal(0, direct.Entities.Count); + Assert.Equal(0, direct.Objects.ObjectCount); + Assert.Equal(0, graphical.EntityObjects.Entities.Count); + Assert.Equal(0, graphical.Objects.ObjectCount); + } + + [Fact] + public void GraphicalObserverFailure_DoesNotStarveLaterObserverOrOwner() + { + using var harness = new Harness(); + var throwing = new ThrowingEntityObserver(); + var recording = new RuntimeTraceRecorder(); + IDisposable first = harness.Runtime.Subscribe(throwing); + IDisposable second = harness.Runtime.Subscribe(recording); + + LiveEntityRecord record = + harness.Entities.RegisterAndMaterializeProjection(Spawn( + Harness.TargetGuid, + instance: 4, + cell: 0x12340001u)); + + Assert.True(harness.EntityObjects.Entities.IsCurrent(record.Canonical)); + Assert.Contains( + recording.Entries, + entry => entry.Kind == RuntimeTraceKind.Entity + && entry.PrimaryObjectId == Harness.TargetGuid); + Assert.Equal(1, harness.EntityObjects.Events.SubscriberCount); + Assert.Equal(0, harness.EntityObjects.Events.DispatchFailureCount); + + first.Dispose(); + second.Dispose(); + Assert.Equal(0, harness.EntityObjects.Events.SubscriberCount); + } + + private static ClientObject Object(WorldSession.EntitySpawn spawn) => new() + { + ObjectId = spawn.Guid, + Name = spawn.Name ?? string.Empty, + StackSize = 4, + Value = 25, + ContainerId = Harness.PlayerGuid, + ContainerSlot = 2, + }; + + private static WorldSession.EntityPositionUpdate Position( + uint guid, + ushort instance) => + new( + guid, + new CreateObject.ServerPosition( + 0x12350001u, + 30f, + 40f, + 8f, + 1f, + 0f, + 0f, + 0f), + Velocity: new Vector3(1f, 2f, 3f), + PlacementId: null, + IsGrounded: true, + InstanceSequence: instance, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + private sealed class Harness : IDisposable { public const uint PlayerGuid = 0x50000002u; @@ -185,10 +352,12 @@ public sealed class CurrentGameRuntimeAdapterTests public Harness() { Identity = new LocalPlayerIdentityState(); - Entities = LiveEntityRuntimeFixture.Create( + EntityObjects = new RuntimeEntityObjectLifetime(); + Entities = new LiveEntityRuntime( new GpuWorldState(), - new NoopEntityResources()); - Objects = new ClientObjectTable(); + new NoopEntityResources(), + EntityObjects); + Objects = EntityObjects.Objects; Chat = new ChatLog(); Selection = new SelectionState(); MovementInput = new DispatcherMovementInputSource(); @@ -233,7 +402,7 @@ public sealed class CurrentGameRuntimeAdapterTests Commands, Identity, Entities, - Objects, + EntityObjects, Chat, new LocalPlayerControllerSlot(), WorldReveal, @@ -246,6 +415,7 @@ public sealed class CurrentGameRuntimeAdapterTests public RuntimeOptions Options { get; } public LocalPlayerIdentityState Identity { get; } + public RuntimeEntityObjectLifetime EntityObjects { get; } public LiveEntityRuntime Entities { get; } public ClientObjectTable Objects { get; } public ChatLog Chat { get; } @@ -541,6 +711,64 @@ public sealed class CurrentGameRuntimeAdapterTests ActivationType activation) => false; } + private sealed class EntityObjectTrace : IRuntimeEntityObjectObserver + { + public List Entries { get; } = []; + + public void OnEntity(in RuntimeEntityDelta delta) => + Entries.Add( + $"E:{delta.Stamp.Sequence}:{delta.Change}:" + + $"{delta.Entity.Identity.ServerGuid:X8}:" + + $"{delta.Entity.Identity.LocalEntityId}:" + + $"{delta.Entity.Identity.Incarnation}:" + + $"{delta.Entity.CellId:X8}:" + + $"{delta.Entity.PhysicsState:X8}:" + + $"{delta.Entity.Position?.ObjCellId:X8}:" + + $"{delta.Entity.Position?.Frame.Origin.X}:" + + $"{delta.Entity.Position?.Frame.Origin.Y}:" + + $"{delta.Entity.Position?.Frame.Origin.Z}"); + + public void OnInventory(in RuntimeInventoryDelta delta) => + Entries.Add( + $"I:{delta.Stamp.Sequence}:{delta.Change}:" + + $"{delta.Item.ObjectId:X8}:" + + $"{delta.Item.Incarnation}:" + + $"{delta.Item.ContainerId:X8}:" + + $"{delta.Item.ContainerSlot}:" + + $"{delta.Item.StackSize}:" + + $"{delta.Item.Value}"); + } + + private sealed class ThrowingEntityObserver : IRuntimeEventObserver + { + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) => + throw new InvalidOperationException("observer"); + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + } + private sealed class SelectionQuery(uint target) : IWorldSelectionQuery { public uint? PickAtCursor(bool includeSelf) => target; diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index 7303b3ef..c7751b5f 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -1168,7 +1168,8 @@ public sealed class LandblockPresentationPipelineTests nearRadius: 0, farRadius: 0, onLandblockLoaded: _ => recoveryCalls++, - ensureEnvCellMeshes: _ => replayCalls++); + ensureEnvCellMeshes: _ => replayCalls++, + workBudgetOptions: GenerousWorkBudget()); Assert.Throws(() => controller.Tick(0x80, 0x80)); Assert.True(state.IsNearTier(landblockId)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index 60eab374..b74f8ac6 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -80,7 +80,7 @@ public sealed class LiveEntityHydrationControllerTests RuntimeEntityRecord canonical = fixture.Canonical; Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.Equal(0, fixture.Resources.RegisterCount); Assert.Empty(fixture.Materializer.Calls); @@ -134,7 +134,7 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(spawn); RuntimeEntityRecord canonical = fixture.Canonical; Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); @@ -522,7 +522,7 @@ public sealed class LiveEntityHydrationControllerTests RuntimeEntityRecord canonical = fixture.Canonical; Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -852,7 +852,7 @@ public sealed class LiveEntityHydrationControllerTests RuntimeEntityRecord replacement = fixture.Canonical; Assert.Equal((ushort)2, replacement.Generation); Assert.Equal("replacement", replacement.Snapshot.Name); - Assert.Null(replacement.LocalEntityId); + Assert.NotNull(replacement.LocalEntityId); Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); Assert.Equal(0, fixture.Runtime.PendingTeardownCount); } @@ -1368,7 +1368,7 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); RuntimeEntityRecord canonical = fixture.Canonical; - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); Assert.Equal(0, fixture.Resources.RegisterCount); diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index 1a86dda0..b77cfefa 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -312,7 +312,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.True(runtime.TryGetCanonical(guid, out var replacement)); Assert.Equal((ushort)2, replacement.Incarnation); - Assert.Null(replacement.LocalEntityId); + Assert.NotNull(replacement.LocalEntityId); Assert.Null(replacement.PhysicsBody); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Null(retired.RemoteMotionRuntime); diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index f524c43f..e5d9e222 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -1656,7 +1656,7 @@ public sealed class LiveEntityRuntimeTests Assert.False(runtime.TryGetRecord(spawn.Guid, out _)); RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid); - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.Equal(0, runtime.MaterializedCount); Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); @@ -1901,7 +1901,7 @@ public sealed class LiveEntityRuntimeTests Assert.NotNull(replacement.PriorGenerationCleanupFailure); RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid); Assert.Equal((ushort)2, canonical.Generation); - Assert.Null(canonical.LocalEntityId); + Assert.NotNull(canonical.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); WorldEntity installed = runtime.MaterializeLiveEntity( guid, @@ -2459,7 +2459,7 @@ public sealed class LiveEntityRuntimeTests && exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal)); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)2, current.Generation); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Empty(spatial.Entities); Assert.DoesNotContain( @@ -2493,7 +2493,7 @@ public sealed class LiveEntityRuntimeTests Assert.False(outer.LogicalRegistrationCreated); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Empty(spatial.Entities); Assert.Equal(1, resources.RegisterCount); @@ -2524,8 +2524,8 @@ public sealed class LiveEntityRuntimeTests Assert.True(outer.LogicalRegistrationCreated); RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid); Assert.Equal((ushort)2, replacement.Generation); - Assert.Null(replacement.LocalEntityId); - Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId); + Assert.NotNull(replacement.LocalEntityId); + Assert.NotNull(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.False(runtime.TryGetRecord(unrelatedGuid, out _)); Assert.Equal(2, runtime.Count); @@ -2587,7 +2587,7 @@ public sealed class LiveEntityRuntimeTests Assert.False(outer.LogicalRegistrationCreated); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -2619,7 +2619,7 @@ public sealed class LiveEntityRuntimeTests && exception.Message == "old incarnation cleanup failed"); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -2671,7 +2671,7 @@ public sealed class LiveEntityRuntimeTests Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)1, current.Generation); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -2695,7 +2695,7 @@ public sealed class LiveEntityRuntimeTests Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); RuntimeEntityRecord current = CurrentCanonical(runtime, guid); - Assert.Null(current.LocalEntityId); + Assert.NotNull(current.LocalEntityId); Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); diff --git a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs index 4c136bda..931b3640 100644 --- a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs @@ -4,8 +4,11 @@ using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Runtime; using AcDream.App.Streaming; using AcDream.App.World; +using AcDream.Core.Items; +using AcDream.Runtime; using AcDream.Runtime.Entities; namespace AcDream.App.Tests.World; @@ -230,6 +233,46 @@ public sealed class RuntimeEntityOwnershipTests property => IsPresentationType(property.PropertyType)); } + [Fact] + public void CurrentRuntimeAdapters_DoNotRetainEntityOrInventoryMirrors() + { + FieldInfo[] viewFields = typeof(CurrentGameRuntimeViewAdapter) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + Assert.Contains( + viewFields, + field => field.FieldType == typeof(IRuntimeEntityView)); + Assert.Contains( + viewFields, + field => field.FieldType == typeof(IRuntimeInventoryView)); + Assert.DoesNotContain( + typeof(CurrentGameRuntimeViewAdapter).GetNestedTypes( + BindingFlags.NonPublic), + type => type.Name is "EntityView" or "InventoryView"); + + FieldInfo[] eventFields = typeof(CurrentGameRuntimeEventAdapter) + .GetFields(BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain( + eventFields, + field => field.FieldType == typeof(LiveEntityRuntime) + || field.FieldType == typeof(ClientObjectTable) + || field.FieldType == typeof(RuntimeEventSequencer)); + Assert.Contains( + eventFields, + field => field.FieldType + == typeof(RuntimeEntityObjectLifetime)); + + string root = FindRepositoryRoot(); + string liveSource = File.ReadAllText(Path.Combine( + root, + "src", + "AcDream.App", + "World", + "LiveEntityRuntime.cs")); + Assert.DoesNotContain("_entityObjects.PublishEntity", liveSource); + Assert.DoesNotContain("_directory.TryApply", liveSource); + Assert.DoesNotContain("_directory.RemoveActive", liveSource); + } + private static bool IsPresentationType(Type type) { string? ns = type.Namespace; diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs index 86309e33..f48b8f02 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityObjectLifetimeTests.cs @@ -1,6 +1,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Core.Items; using AcDream.Runtime.Entities; namespace AcDream.Runtime.Tests.Entities; @@ -120,14 +121,19 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.False(lifetime.TryAcceptDelete( new DeleteObject.Parsed(spawn.Guid, 7), isLocalPlayer: false, + removeRetainedObject: true, out _)); Assert.NotNull(lifetime.Objects.Get(spawn.Guid)); Assert.True(lifetime.TryAcceptDelete( new DeleteObject.Parsed(spawn.Guid, 6), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance accepted)); + Assert.Same(canonical, accepted.RetiredCanonical); + Assert.False(lifetime.Entities.TryGetActive(spawn.Guid, out _)); lifetime.CompleteAcceptedDelete(accepted); + Assert.Null(lifetime.RetireCanonicalOnly(canonical)); Assert.Null(lifetime.Objects.Get(spawn.Guid)); } @@ -146,6 +152,7 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.True(owner.TryAcceptDelete( new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance acceptance)); Assert.Throws( @@ -175,6 +182,509 @@ public sealed class RuntimeEntityObjectLifetimeTests Assert.Same(canonical, retained); } + [Fact] + public void CanonicalRegistration_IssuesIdentityBeforeAnyProjection() + { + var lifetime = new RuntimeEntityObjectLifetime(); + WorldSession.EntitySpawn spawn = Spawn( + 0x70000010u, + 4, + "direct"); + RuntimeEntityRecord canonical = Accept(lifetime, spawn); + + Assert.Equal( + RuntimeEntityDirectory.FirstLocalEntityId, + canonical.LocalEntityId); + Assert.True(lifetime.EntityView.TryGet( + spawn.Guid, + out RuntimeEntitySnapshot snapshot)); + Assert.Equal(canonical.LocalEntityId, snapshot.Identity.LocalEntityId); + Assert.Equal(spawn.InstanceSequence, snapshot.Identity.Incarnation); + Assert.Equal(spawn.Position!.Value.LandblockId, snapshot.CellId); + } + + [Fact] + public void EntityAndInventoryCommits_ShareOnePerGenerationSequence() + { + var lifetime = new RuntimeEntityObjectLifetime(); + RuntimeGenerationToken generation = new(8); + ulong frame = 20; + lifetime.BindEventContext(() => generation, () => frame); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + WorldSession.EntitySpawn spawn = Spawn( + 0x70000011u, + 2, + "ordered"); + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(spawn).Canonical!; + + Assert.True(lifetime.ApplyAcceptedSpawn( + canonical, + canonical.CreateIntegrationVersion, + spawn, + replaceGeneration: false)); + frame++; + lifetime.Objects.MoveItem( + spawn.Guid, + newContainerId: 0x50000001u, + newSlot: 3); + _ = lifetime.RegisterEntity(spawn); + + Assert.Equal( + [ + (RuntimeTraceKind.Entity, 1UL), + (RuntimeTraceKind.Inventory, 2UL), + (RuntimeTraceKind.Inventory, 3UL), + (RuntimeTraceKind.Entity, 4UL), + ], + observer.Order); + Assert.All( + observer.Stamps, + stamp => Assert.Equal(generation, stamp.Generation)); + Assert.Equal(20UL, observer.Stamps[0].FrameNumber); + Assert.Equal(21UL, observer.Stamps[^1].FrameNumber); + + generation = new RuntimeGenerationToken(9); + lifetime.ClearObjects(); + Assert.Equal(1UL, observer.Stamps[^1].Sequence); + Assert.Equal(generation, observer.Stamps[^1].Generation); + } + + [Fact] + public void InventoryView_UsesCanonicalRuntimeIncarnationWithoutApp() + { + var lifetime = new RuntimeEntityObjectLifetime(); + WorldSession.EntitySpawn spawn = Spawn( + 0x70000012u, + 12, + "inventory"); + RuntimeEntityRecord canonical = Accept(lifetime, spawn); + Assert.True(lifetime.ApplyAcceptedSpawn( + canonical, + canonical.CreateIntegrationVersion, + spawn, + replaceGeneration: false)); + + Assert.True(lifetime.InventoryView.TryGet( + spawn.Guid, + out RuntimeInventoryItemSnapshot item)); + Assert.Equal((ushort)12, item.Incarnation); + Assert.Equal("inventory", item.Name); + } + + [Fact] + public void UnknownConfirmedMove_RetainsIdentityAndPlacementInStream() + { + var lifetime = new RuntimeEntityObjectLifetime(); + var observer = new RecordingObserver(); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + + Assert.False(lifetime.Objects.ApplyConfirmedServerMove( + 0x7000DEADu, + newContainerId: 0x50000001u, + newWielderId: 0u, + newSlot: 7)); + + RuntimeInventoryDelta moved = Assert.Single(observer.Inventory); + Assert.Equal(RuntimeInventoryChange.Moved, moved.Change); + Assert.Equal(0x7000DEADu, moved.Item.ObjectId); + Assert.Equal(0x50000001u, moved.Item.ContainerId); + Assert.Equal(7, moved.Item.ContainerSlot); + } + + [Fact] + public void EventSubscriptions_AreExactAndDisposeWithoutRetention() + { + var lifetime = new RuntimeEntityObjectLifetime(); + var observer = new RecordingObserver(); + IDisposable subscription = lifetime.Events.Subscribe(observer); + + Assert.Equal(1, lifetime.Events.SubscriberCount); + Assert.Throws( + () => 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( + () => 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( + 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( + () => 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 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(outer.Canonical).Incarnation); + } + + [Fact] + public void AcceptedUpdate_RevalidatesAfterReentrantTerminalObserver() + { + var lifetime = new RuntimeEntityObjectLifetime(); + const uint guid = 0x70000023u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, 1, "vector")).Canonical!; + bool deleted = false; + var observer = new CallbackObserver( + onEntity: delta => + { + if (deleted + || delta.Change is not RuntimeEntityChange.Updated) + { + return; + } + + deleted = lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, 1), + isLocalPlayer: false, + removeRetainedObject: false, + out RuntimeEntityDeleteAcceptance acceptance); + if (deleted) + { + lifetime.CompleteAcceptedDelete(acceptance); + Assert.Null(lifetime.RetireCanonicalOnly(canonical)); + } + }); + using IDisposable subscription = lifetime.Events.Subscribe(observer); + + bool remainedCurrent = lifetime.TryApplyVector( + new VectorUpdate.Parsed( + guid, + new System.Numerics.Vector3(1f, 0f, 0f), + System.Numerics.Vector3.Zero, + InstanceSequence: 1, + VectorSequence: 2), + acknowledgeProjection: null, + out _); + + Assert.True(deleted); + Assert.False(remainedCurrent); + Assert.False(lifetime.Entities.TryGetActive(guid, out _)); + } + private static RuntimeEntityRecord Accept( RuntimeEntityObjectLifetime lifetime, WorldSession.EntitySpawn spawn) @@ -248,4 +758,38 @@ public sealed class RuntimeEntityObjectLifetimeTests PositionSequence: 1, Physics: physics); } + + private sealed class RecordingObserver : IRuntimeEntityObjectObserver + { + public List<(RuntimeTraceKind Kind, ulong Sequence)> Order { get; } = []; + public List Stamps { get; } = []; + public List Entities { get; } = []; + public List 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? onEntity = null, + Action? onInventory = null) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => + onEntity?.Invoke(delta); + + public void OnInventory(in RuntimeInventoryDelta delta) => + onInventory?.Invoke(delta); + } } diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs index 2d72f02c..373e71c6 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeContractTests.cs @@ -80,6 +80,21 @@ public sealed class GameRuntimeContractTests Assert.Equal(99UL, independent.FrameNumber); } + [Fact] + public void EventSequencer_RestartsAtOneForEachSessionGeneration() + { + var sequencer = new RuntimeEventSequencer(); + + RuntimeEventStamp first = sequencer.Next(new(4), 10); + RuntimeEventStamp second = sequencer.Next(new(4), 11); + RuntimeEventStamp replacement = sequencer.Next(new(5), 12); + + Assert.Equal(1UL, first.Sequence); + Assert.Equal(2UL, second.Sequence); + Assert.Equal(1UL, replacement.Sequence); + Assert.Equal(new RuntimeGenerationToken(5), replacement.Generation); + } + [Fact] public void TraceRecorderNormalizesTypedDeltasWithoutRetainingOwners() {