From f46ddb5cdb1e145752bea49aeb1d62bfe71284d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 20:07:14 +0200 Subject: [PATCH] refactor(runtime): own canonical entity identity and incarnations Introduce the presentation-free RuntimeEntityDirectory and RuntimeEntityRecord as the sole owners of server GUIDs, INSTANCE_TS incarnations, accepted snapshots, authority versions, retryable tombstones, session epochs, and local-ID allocation. Convert LiveEntityRuntime into an App projection sidecar host resolved through canonical record identity without a second GUID map. Preserve the existing synchronous and reentrant projection lifecycle, including retryable registration/delete/session teardown. Add Runtime-only identity/lifetime tests and App ownership guards. Validated by the Release solution build and 8,441 complete Release tests with five existing skips. Co-authored-by: Codex --- src/AcDream.App/World/LiveEntityRuntime.cs | 548 +++++++++++------- .../Entities/RuntimeEntityDirectory.cs | 428 ++++++++++++++ .../Entities/RuntimeEntityRecord.cs | 160 +++++ .../World/RuntimeEntityOwnershipTests.cs | 76 +++ .../Entities/RuntimeEntityDirectoryTests.cs | 231 ++++++++ 5 files changed, 1218 insertions(+), 225 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs create mode 100644 src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs create mode 100644 tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeEntityDirectoryTests.cs diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index ced3ef7b..3a2c3b23 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -209,48 +209,78 @@ public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLif } /// -/// The one logical record for a server object incarnation. The record survives -/// loaded/pending landblock movement and temporary loss of its render bucket. -/// Only an accepted DeleteObject, session reset, newer INSTANCE_TS, or the -/// active side of the leave-visibility lifecycle ends it. ACE-compatible -/// cold snapshot ownership lives outside this active runtime. +/// App projection sidecar for one canonical . +/// The canonical record survives loaded/pending landblock movement and +/// temporary loss of its render bucket; this sidecar retains only graphical +/// components, visibility, hydration, and teardown progress. /// public sealed class LiveEntityRecord { - private readonly Queue _pendingStateTransitions = new(); + private readonly RuntimeEntityDirectory _directory; - internal LiveEntityRecord(WorldSession.EntitySpawn snapshot) + internal LiveEntityRecord( + RuntimeEntityDirectory directory, + RuntimeEntityRecord canonical) { - ServerGuid = snapshot.Guid; - Snapshot = snapshot; - RefreshDerivedState(); - PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL; - VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL; - VelocityAuthorityVersion = snapshot.Position is null - && snapshot.Physics is null - ? 0UL - : 1UL; - MovementAuthorityVersion = 1UL; - ObjDescAuthorityVersion = 1UL; - FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; - ApplyRawPhysicsState(RawPhysicsState); + _directory = directory ?? throw new ArgumentNullException(nameof(directory)); + Canonical = canonical ?? throw new ArgumentNullException(nameof(canonical)); } - public uint ServerGuid { get; } - public ushort Generation => Snapshot.InstanceSequence; - public WorldSession.EntitySpawn Snapshot { get; internal set; } + /// + /// Detached presentation fixture used by App component tests which do not + /// exercise live identity lookup. Production records are always created + /// from the session's shared RuntimeEntityDirectory. + /// + internal LiveEntityRecord(WorldSession.EntitySpawn snapshot) + : this(new RuntimeEntityDirectory(), snapshot) + { + } + + private LiveEntityRecord( + RuntimeEntityDirectory directory, + WorldSession.EntitySpawn snapshot) + : this(directory, directory.AddActive(snapshot)) + { + } + + internal RuntimeEntityRecord Canonical { get; } + public uint ServerGuid => Canonical.ServerGuid; + public ushort Generation => Canonical.Incarnation; + public WorldSession.EntitySpawn Snapshot + { + get => Canonical.Snapshot; + internal set => _directory.RefreshSnapshot(Canonical, value); + } public WorldEntity? WorldEntity { get; internal set; } - public uint? LocalEntityId => WorldEntity?.Id; - public uint FullCellId { get; internal set; } - public uint CanonicalLandblockId { get; internal set; } - public uint RawPhysicsState { get; internal set; } - public PhysicsStateFlags FinalPhysicsState { get; internal set; } + public uint? LocalEntityId => Canonical.LocalEntityId ?? WorldEntity?.Id; + public uint FullCellId + { + get => Canonical.FullCellId; + internal set => _directory.SetFullCell( + Canonical, + value, + Canonical.CanonicalLandblockId); + } + public uint CanonicalLandblockId + { + get => Canonical.CanonicalLandblockId; + internal set => _directory.SetFullCell( + Canonical, + Canonical.FullCellId, + value); + } + public uint RawPhysicsState => Canonical.RawPhysicsState; + public PhysicsStateFlags FinalPhysicsState + { + get => Canonical.FinalPhysicsState; + internal set => _directory.SetFinalPhysicsState(Canonical, value); + } /// /// Incarnation-stable retail CPhysicsObj::update_time owner. It /// survives animation and MovementManager attachment/replacement, spatial /// rebucketing, and appearance rehydration, and dies with this record. /// - public RetailObjectQuantumClock ObjectClock { get; } = new(); + public RetailObjectQuantumClock ObjectClock => Canonical.ObjectClock; /// /// Changes whenever this record leaves or re-enters the ordinary-object /// workset. Unlike , loaded-to-loaded @@ -258,20 +288,36 @@ public sealed class LiveEntityRecord /// catch-up batch that crossed a leave/enter boundary without rejecting an /// ordinary same-frame cell move. /// - internal ulong ObjectClockEpoch { get; private set; } + internal ulong ObjectClockEpoch => Canonical.ObjectClockEpoch; /// /// True after this incarnation has successfully constructed its retail /// CPartArray. This is an object-lifetime fact, not an animation- /// runtime proxy: static poses and objects without a MotionTable still /// own a PartArray and participate in the 96-unit Active gate. /// - public bool HasPartArray { get; internal set; } - public PhysicsBody? PhysicsBody { get; internal set; } - internal bool PhysicsBodyAcquisitionInProgress { get; set; } + public bool HasPartArray + { + get => Canonical.HasPartArray; + internal set => _directory.SetHasPartArray(Canonical, value); + } + public PhysicsBody? PhysicsBody + { + get => Canonical.PhysicsBody; + internal set => _directory.SetPhysicsBody(Canonical, value); + } + internal bool PhysicsBodyAcquisitionInProgress + { + get => Canonical.PhysicsBodyAcquisitionInProgress; + set => _directory.SetPhysicsBodyAcquisitionInProgress(Canonical, value); + } public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; } public ILiveEntityRemoteMotionRuntime? RemoteMotionRuntime { get; internal set; } internal bool RemoteMotionBindingInProgress { get; set; } - public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; } + public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost + { + get => Canonical.PhysicsHost; + internal set => _directory.SetPhysicsHost(Canonical, value); + } internal bool RequiresRemotePlacementRuntime { get; set; } public ILiveEntityProjectileRuntime? ProjectileRuntime { get; internal set; } public ILiveEntityEffectProfile? EffectProfile { get; internal set; } @@ -284,18 +330,18 @@ public sealed class LiveEntityRecord /// handler detect a newer same-incarnation packet delivered re-entrantly /// while an older packet is crossing presentation or spatial callbacks. /// - internal ulong PositionAuthorityVersion { get; private set; } - internal ulong StateAuthorityVersion { get; private set; } - internal ulong VectorAuthorityVersion { get; private set; } - internal ulong VelocityAuthorityVersion { get; private set; } - internal ulong MovementAuthorityVersion { get; private set; } - internal ulong ObjDescAuthorityVersion { get; private set; } + internal ulong PositionAuthorityVersion => Canonical.PositionAuthorityVersion; + internal ulong StateAuthorityVersion => Canonical.StateAuthorityVersion; + internal ulong VectorAuthorityVersion => Canonical.VectorAuthorityVersion; + internal ulong VelocityAuthorityVersion => Canonical.VelocityAuthorityVersion; + internal ulong MovementAuthorityVersion => Canonical.MovementAuthorityVersion; + internal ulong ObjDescAuthorityVersion => Canonical.ObjDescAuthorityVersion; /// /// Advances for every accepted CreateObject affecting this incarnation, /// including a fresher same-INSTANCE_TS retransmit. App integration uses /// it to distinguish two transactions that intentionally share identity. /// - internal ulong CreateIntegrationVersion { get; private set; } = 1UL; + internal ulong CreateIntegrationVersion => Canonical.CreateIntegrationVersion; public bool WorldSpawnPublished { get; internal set; } /// /// True only after the incarnation's projection, collision/animation @@ -330,82 +376,45 @@ public sealed class LiveEntityRecord internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; } internal bool SpatialProjectionTeardownCompleted { get; set; } internal bool TeardownInProgress { get; set; } - internal bool DeleteAcceptedForTeardown { get; set; } + internal bool DeleteAcceptedForTeardown + { + get => Canonical.DeleteAcceptedForTeardown; + set => _directory.SetDeleteAcceptedForTeardown(Canonical, value); + } internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) => - _pendingStateTransitions.TryDequeue(out transition); + _directory.TryDequeueStateTransition(Canonical, out transition); - internal void SuspendObjectClock() - { - ObjectClock.Deactivate(); - ObjectClockEpoch++; - } + internal void SuspendObjectClock() => _directory.SuspendObjectClock(Canonical); - internal void ResetObjectClockForEnterWorld(bool isStatic) - { - ObjectClock.ResetForEnterWorld(isStatic); - ObjectClockEpoch++; - } + internal void ResetObjectClockForEnterWorld(bool isStatic) => + _directory.ResetObjectClockForEnterWorld(Canonical, isStatic); - internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) - { - StateAuthorityVersion++; - RawPhysicsState = rawState; - RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( - FinalPhysicsState, - (PhysicsStateFlags)rawState); - FinalPhysicsState = transition.FinalState; - if (PhysicsBody is not null) - PhysicsBody.State = FinalPhysicsState; - _pendingStateTransitions.Enqueue(transition); - return transition; - } + internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) => + _directory.ApplyRawPhysicsState(Canonical, rawState); - internal void AdvancePositionAuthority() - { - PositionAuthorityVersion++; - VelocityAuthorityVersion++; - } + internal void AdvancePositionAuthority() => + _directory.AdvancePositionAuthority(Canonical); - internal void AdvanceVectorAuthority() - { - VectorAuthorityVersion++; - VelocityAuthorityVersion++; - } + internal void AdvanceVectorAuthority() => + _directory.AdvanceVectorAuthority(Canonical); - internal void AdvanceMovementAuthority() - { - MovementAuthorityVersion++; - VelocityAuthorityVersion++; - } + internal void AdvanceMovementAuthority() => + _directory.AdvanceMovementAuthority(Canonical); internal void AdvanceObjDescAuthority() { - ObjDescAuthorityVersion++; + _directory.AdvanceObjDescAuthority(Canonical); AppearanceProjectionSynchronizationPending = true; if (AppearanceHydrationInProgress) AppearanceHydrationRetryRequested = true; } - internal void AdvanceCreateAuthority() - { - PositionAuthorityVersion++; - StateAuthorityVersion++; - VectorAuthorityVersion++; - VelocityAuthorityVersion++; - MovementAuthorityVersion++; - ObjDescAuthorityVersion++; - CreateIntegrationVersion++; - } + internal void AdvanceCreateAuthority() => + _directory.AdvanceCreateAuthority(Canonical); - internal void SetChildNoDraw(bool noDraw) - { - FinalPhysicsState = noDraw - ? FinalPhysicsState | PhysicsStateFlags.NoDraw - : FinalPhysicsState & ~PhysicsStateFlags.NoDraw; - if (PhysicsBody is not null) - PhysicsBody.State = FinalPhysicsState; - } + internal void SetChildNoDraw(bool noDraw) => + _directory.SetChildNoDraw(Canonical, noDraw); /// /// Cell used when a streamed landblock asks live objects to restore their @@ -416,17 +425,8 @@ public sealed class LiveEntityRecord ? FullCellId : Snapshot.Position?.LandblockId ?? FullCellId; - internal void RefreshDerivedState(bool refreshPosition = true) - { - if (refreshPosition && Snapshot.Position is { } position) - { - FullCellId = position.LandblockId; - CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu; - } - RawPhysicsState = Snapshot.Physics?.RawState - ?? Snapshot.PhysicsState - ?? 0u; - } + internal void RefreshDerivedState(bool refreshPosition = true) => + _directory.RefreshSnapshot(Canonical, Snapshot, refreshPosition); } public readonly record struct LiveEntityRegistrationResult( @@ -437,10 +437,13 @@ public readonly record struct LiveEntityRegistrationResult( Exception? PriorGenerationCleanupFailure = null); /// -/// Update-thread owner of live server-object identity, accepted state, runtime -/// components, and logical teardown. is a spatial -/// projection only; moving between loaded and pending buckets never replays a -/// create-time renderer, script, animation, or effect action. +/// Update-thread App projection/lifecycle host over the canonical +/// . Runtime owns server GUIDs, +/// incarnations, accepted snapshots, timestamps, tombstones, and local IDs; +/// this host owns graphical components and retryable projection teardown. +/// is a spatial projection only; moving between +/// loaded and pending buckets never replays a create-time renderer, script, +/// animation, or effect action. /// /// /// Retail keeps one object per accepted INSTANCE_TS: same-generation @@ -454,23 +457,31 @@ public readonly record struct LiveEntityRegistrationResult( /// public sealed class LiveEntityRuntime { - public const uint FirstLiveEntityId = 1_000_000u; - public const uint LastLiveEntityId = 0x3FFF_FFFFu; + public const uint FirstLiveEntityId = RuntimeEntityDirectory.FirstLocalEntityId; + public const uint LastLiveEntityId = RuntimeEntityDirectory.LastLocalEntityId; private readonly GpuWorldState _spatial; private readonly ILiveEntityResourceLifecycle _resources; private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle; - private readonly InboundPhysicsStateController _inbound = new(); - private readonly Dictionary _recordsByGuid = new(); + private readonly RuntimeEntityDirectory _directory; + // App sidecars are keyed by the canonical Runtime record itself. Runtime + // owns the only server-GUID/incarnation maps; these collections only + // attach presentation state to records already accepted by that owner. + private readonly Dictionary _activeRecords = new(); + // Transitional dictionary-shaped adapter for the existing App projection + // methods. It owns no GUID map: every lookup and mutation is resolved + // through RuntimeEntityDirectory, then translated to the App sidecar by + // canonical record reference. J3.3 removes this facade with the projection + // store cutover. + private readonly ActiveRecordView _recordsByGuid; // Records leave the active gameplay map before arbitrary teardown callbacks // so a newer GUID generation can be accepted re-entrantly. They remain // canonical teardown tombstones until every resource owner confirms // unregister; failures are therefore retryable by Delete or session Clear. - private readonly Dictionary<(uint Guid, ushort Generation), LiveEntityRecord> + private readonly Dictionary _teardownRecords = new(); private readonly Dictionary _materializedWorldEntitiesByGuid = new(); private readonly Dictionary _visibleWorldEntitiesByGuid = new(); - private readonly Dictionary _guidByLocalId = new(); private readonly Dictionary _animationsByLocalId = new(); private readonly Dictionary _remoteMotionByGuid = new(); // Logical components survive retail's 25-second leave-visibility lifetime. @@ -489,10 +500,7 @@ public sealed class LiveEntityRuntime private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; private int _logicalTeardownDepth; - private ulong _sessionLifetimeVersion; - private readonly Dictionary _lifetimeMutationVersionByGuid = new(); private uint _rebucketingGuid; - private uint _nextLocalEntityId; public LiveEntityRuntime( GpuWorldState spatial, @@ -529,18 +537,15 @@ public sealed class LiveEntityRuntime _resources = resources ?? throw new ArgumentNullException(nameof(resources)); _runtimeComponentLifecycle = runtimeComponentLifecycle ?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle)); - if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId) - throw new ArgumentOutOfRangeException( - nameof(firstLocalEntityId), - $"Live entity ids must stay in 0x{FirstLiveEntityId:X8}..0x{LastLiveEntityId:X8}."); - _nextLocalEntityId = firstLocalEntityId; + _directory = new RuntimeEntityDirectory(firstLocalEntityId); + _recordsByGuid = new ActiveRecordView(_directory, _activeRecords); _spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged; } - public int Count => _recordsByGuid.Count; - public int PendingTeardownCount => _teardownRecords.Count; - public int MaterializedCount => _guidByLocalId.Count; - public IReadOnlyCollection Records => _recordsByGuid.Values; + public int Count => _directory.Count; + public int PendingTeardownCount => _directory.PendingTeardownCount; + public int MaterializedCount => _directory.ClaimedLocalIdCount; + public IReadOnlyCollection Records => _activeRecords.Values; /// /// Every materialized top-level world projection, including an object /// parked in a pending (currently unloaded) spatial bucket. Network and @@ -558,7 +563,7 @@ public sealed class LiveEntityRuntime /// public IReadOnlyDictionary WorldEntities => _visibleWorldEntitiesByGuid; - public IReadOnlyDictionary Snapshots => _inbound.Snapshots; + public IReadOnlyDictionary Snapshots => _directory.Snapshots; public IReadOnlyDictionary AnimationRuntimes => _animationsByLocalId; public IReadOnlyDictionary RemoteMotionRuntimes => _remoteMotionByGuid; public IReadOnlyDictionary SpatialAnimationRuntimes => @@ -569,7 +574,7 @@ public sealed class LiveEntityRuntime _spatialProjectilesByGuid; internal IReadOnlyDictionary SpatialRootObjects => _spatialRootObjectsByGuid; - public ParentAttachmentState ParentAttachments { get; } = new(); + public ParentAttachmentState ParentAttachments => _directory.ParentAttachments; /// /// Raised after a materialized projection enters or leaves the currently @@ -587,10 +592,10 @@ public sealed class LiveEntityRuntime : "A live entity cannot register from inside atomic resource registration."); } - InboundCreateResult result = _inbound.AcceptCreate(incoming); + InboundCreateResult result = _directory.AcceptCreate(incoming); if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) return new LiveEntityRegistrationResult(result, null, false, false); - ulong sessionVersion = _sessionLifetimeVersion; + ulong sessionVersion = _directory.SessionLifetimeVersion; ulong operationVersion = AdvanceLifetimeMutation(incoming.Guid); if (result.Disposition is CreateObjectTimestampDisposition.ExistingGeneration) @@ -608,8 +613,10 @@ public sealed class LiveEntityRuntime // 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 (_teardownRecords.ContainsKey( - (incoming.Guid, result.Snapshot.InstanceSequence))) + if (_directory.TryGetTeardown( + incoming.Guid, + result.Snapshot.InstanceSequence, + out _)) { return new LiveEntityRegistrationResult( result, @@ -620,7 +627,9 @@ public sealed class LiveEntityRuntime // Defensive repair for a caller that cleared only the logical map. // Normal session teardown clears both owners together. - var recovered = new LiveEntityRecord(result.Snapshot); + RuntimeEntityRecord recoveredCanonical = + _directory.AddActive(result.Snapshot); + var recovered = new LiveEntityRecord(_directory, recoveredCanonical); _recordsByGuid.Add(incoming.Guid, recovered); return new LiveEntityRegistrationResult(result, recovered, true, false); } @@ -657,7 +666,7 @@ public sealed class LiveEntityRuntime // 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 (_sessionLifetimeVersion != sessionVersion + if (_directory.SessionLifetimeVersion != sessionVersion || CurrentLifetimeMutation(incoming.Guid) != operationVersion) { if (tearDownFailure is not null) @@ -675,7 +684,8 @@ public sealed class LiveEntityRuntime tearDownFailure); } - var record = new LiveEntityRecord(result.Snapshot); + RuntimeEntityRecord canonical = _directory.AddActive(result.Snapshot); + var record = new LiveEntityRecord(_directory, canonical); _recordsByGuid.Add(incoming.Guid, record); return new LiveEntityRegistrationResult( result, @@ -709,17 +719,30 @@ public sealed class LiveEntityRuntime if (record.WorldEntity is null) { - uint localId = ReserveLocalEntityId(); - WorldEntity entity = factory(localId) - ?? throw new InvalidOperationException("A live-entity projection factory returned null."); - if (entity.Id != localId) - throw new InvalidOperationException( - $"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}."); - if (entity.ServerGuid != serverGuid) - throw new InvalidOperationException( - $"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}."); + uint localId = _directory.ClaimLocalId(record.Canonical); + WorldEntity entity; + try + { + entity = factory(localId) + ?? throw new InvalidOperationException( + "A live-entity projection factory returned null."); + if (entity.Id != localId) + { + throw new InvalidOperationException( + $"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}."); + } + if (entity.ServerGuid != serverGuid) + { + throw new InvalidOperationException( + $"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}."); + } + } + catch + { + _directory.ReleaseLocalId(record.Canonical); + throw; + } - _guidByLocalId.Add(localId, serverGuid); record.WorldEntity = entity; _isRegisteringResources = true; try @@ -749,7 +772,7 @@ public sealed class LiveEntityRuntime if (rollbackError is null) { - _guidByLocalId.Remove(localId); + _directory.ReleaseLocalId(record.Canonical); record.WorldEntity = null; throw; } @@ -1067,14 +1090,21 @@ public sealed class LiveEntityRuntime "A live entity cannot unregister from inside atomic resource registration."); } var teardownKey = (delete.Guid, delete.InstanceSequence); - _teardownRecords.TryGetValue(teardownKey, out LiveEntityRecord? record); + LiveEntityRecord? record = null; + if (_directory.TryGetTeardown( + teardownKey.Guid, + teardownKey.InstanceSequence, + out RuntimeEntityRecord retainedCanonical)) + { + _teardownRecords.TryGetValue(retainedCanonical, out record); + } bool retryingAcceptedDelete = record is not null && (record.DeleteAcceptedForTeardown || _isClearing || _sessionClearPendingFinalization); if (!retryingAcceptedDelete) { - if (!_inbound.TryDelete(delete, isLocalPlayer)) + if (!_directory.TryDelete(delete, isLocalPlayer)) return false; AdvanceLifetimeMutation(delete.Guid); ParentAttachments.DeleteGeneration(delete.Guid, delete.InstanceSequence); @@ -1226,8 +1256,19 @@ public sealed class LiveEntityRuntime _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) && record.WorldEntity is not null; - public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) => - _guidByLocalId.TryGetValue(localEntityId, out serverGuid); + public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) + { + if (_directory.TryGetByLocalId( + localEntityId, + out RuntimeEntityRecord canonical)) + { + serverGuid = canonical.ServerGuid; + return true; + } + + serverGuid = 0u; + return false; + } public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId) { @@ -1377,7 +1418,7 @@ public sealed class LiveEntityRuntime // exact record, authority epoch, and expected owners are revalidated // before commit just like resource/physics-body acquisition. LiveEntityRecord incarnation = record; - ulong sessionVersion = _sessionLifetimeVersion; + ulong sessionVersion = _directory.SessionLifetimeVersion; ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid); PhysicsBody? expectedBody = record.PhysicsBody; ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime; @@ -1436,7 +1477,7 @@ public sealed class LiveEntityRuntime == expectedIndexPresent && (!expectedIndexPresent || ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime)); - if (_sessionLifetimeVersion != sessionVersion + if (_directory.SessionLifetimeVersion != sessionVersion || CurrentLifetimeMutation(serverGuid) != lifetimeVersion || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) || !ReferenceEquals(current, incarnation) @@ -1592,11 +1633,11 @@ public sealed class LiveEntityRuntime } public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => - _inbound.TryGetSnapshot(guid, out spawn); + _directory.TryGetSnapshot(guid, out spawn); public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) { - bool applied = _inbound.TryApplyObjDesc(update, out accepted); + bool applied = _directory.TryApplyObjDesc(update, out accepted); if (applied) { RefreshRecord(update.Guid, accepted); @@ -1608,7 +1649,7 @@ public sealed class LiveEntityRuntime public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted) { - bool applied = _inbound.TryApplyPickup(update, out accepted); + bool applied = _directory.TryApplyPickup(update, out accepted); if (applied) { RefreshRecord(update.Guid, accepted); @@ -1622,7 +1663,7 @@ public sealed class LiveEntityRuntime public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted) { - bool applied = _inbound.TryApplyCreateParent(update, out accepted); + bool applied = _directory.TryApplyCreateParent(update, out accepted); if (applied) { RefreshRecord(update.ChildGuid, accepted); @@ -1634,7 +1675,7 @@ public sealed class LiveEntityRuntime public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) { - bool applied = _inbound.TryApplyParent(update, out accepted); + bool applied = _directory.TryApplyParent(update, out accepted); if (applied) { RefreshRecord(update.ChildGuid, accepted); @@ -1648,7 +1689,7 @@ public sealed class LiveEntityRuntime ParentAttachmentRelation relation, out WorldSession.EntitySpawn accepted) { - bool committed = _inbound.TryCommitParent( + bool committed = _directory.TryCommitParent( relation.ChildGuid, relation.ParentGuid, relation.ParentLocation, @@ -1683,8 +1724,8 @@ public sealed class LiveEntityRuntime out WorldSession.EntitySpawn accepted, out AcceptedPhysicsTimestamps timestamps) { - bool applied = _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps); - if (_inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) + bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps); + if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) RefreshRecord(update.Guid, snapshot); if (applied && retainPayload @@ -1697,7 +1738,7 @@ public sealed class LiveEntityRuntime public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) { - bool applied = _inbound.TryApplyVector(update, out accepted); + bool applied = _directory.TryApplyVector(update, out accepted); if (applied) { RefreshRecord(update.Guid, accepted); @@ -1715,7 +1756,7 @@ public sealed class LiveEntityRuntime out WorldSession.EntitySpawn accepted, out RetailPhysicsStateTransition transition) { - bool applied = _inbound.TryApplyState(update, out accepted); + bool applied = _directory.TryApplyState(update, out accepted); transition = default; if (applied) { @@ -1757,7 +1798,7 @@ public sealed class LiveEntityRuntime && (beforePosition.FullCellId == 0 || !beforePosition.IsSpatiallyProjected || !beforePosition.IsSpatiallyVisible); - bool known = _inbound.TryApplyPosition( + bool known = _directory.TryApplyPosition( update, isLocalPlayer, forcePositionRotation, @@ -1765,7 +1806,7 @@ public sealed class LiveEntityRuntime out disposition, out accepted, out timestamps); - if (known && _inbound.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) + if (known && _directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) { bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; if (disposition is PositionTimestampDisposition.Apply) @@ -1792,7 +1833,7 @@ public sealed class LiveEntityRuntime } public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) => - _inbound.IsFreshTeleportStart(localPlayerGuid, teleportSequence); + _directory.IsFreshTeleportStart(localPlayerGuid, teleportSequence); /// /// Retail CPhysicsObj::update_object (0x00515D40) runs root @@ -2305,8 +2346,7 @@ public sealed class LiveEntityRuntime _isClearing = true; _sessionClearPendingFinalization = true; - _sessionLifetimeVersion++; - _lifetimeMutationVersionByGuid.Clear(); + _directory.BeginSessionClear(); List? failures = null; try { @@ -2319,11 +2359,16 @@ public sealed class LiveEntityRuntime RetainTeardownRecord(record); } - ParentAttachments.Clear(); - _inbound.Clear(); - foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) { + if (!_teardownRecords.TryGetValue( + record.Canonical, + out LiveEntityRecord? retained) + || !ReferenceEquals(retained, record)) + { + continue; + } + // A re-entrant Clear can observe the tombstone currently being // unregistered by its caller. Leave it queued; the outer // teardown's ReleaseTeardownRecord finalizes the session once @@ -2369,6 +2414,14 @@ public sealed class LiveEntityRuntime List? failures = null; foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) { + if (!_teardownRecords.TryGetValue( + record.Canonical, + out LiveEntityRecord? retained) + || !ReferenceEquals(retained, record)) + { + continue; + } + if (record.TeardownInProgress) continue; @@ -2382,7 +2435,7 @@ public sealed class LiveEntityRuntime completed++; if (!_recordsByGuid.ContainsKey(record.ServerGuid) && !HasPendingTeardown(record.ServerGuid) - && !_inbound.TryGetSnapshot(record.ServerGuid, out _)) + && !_directory.TryGetSnapshot(record.ServerGuid, out _)) { _spatial.ForgetLiveEntity(record.ServerGuid); } @@ -2421,14 +2474,10 @@ public sealed class LiveEntityRuntime default); private ulong AdvanceLifetimeMutation(uint serverGuid) - { - ulong next = _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid) + 1UL; - _lifetimeMutationVersionByGuid[serverGuid] = next; - return next; - } + => _directory.AdvanceLifetimeMutation(serverGuid); private ulong CurrentLifetimeMutation(uint serverGuid) => - _lifetimeMutationVersionByGuid.GetValueOrDefault(serverGuid); + _directory.CurrentLifetimeMutation(serverGuid); private bool IsCurrentProjectionOperation( uint serverGuid, @@ -2574,23 +2623,6 @@ public sealed class LiveEntityRuntime } } - private uint ReserveLocalEntityId() - { - uint start = _nextLocalEntityId; - do - { - uint candidate = _nextLocalEntityId; - _nextLocalEntityId = candidate == LastLiveEntityId - ? FirstLiveEntityId - : candidate + 1u; - if (!_guidByLocalId.ContainsKey(candidate)) - return candidate; - } - while (_nextLocalEntityId != start); - - throw new InvalidOperationException("The live entity id namespace is exhausted."); - } - private void OnSpatialVisibilityChanged(uint serverGuid, bool visible) { // GpuWorldState serializes re-entrant visibility callbacks. A callback @@ -2688,19 +2720,18 @@ public sealed class LiveEntityRuntime _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); } - private static (uint Guid, ushort Generation) TeardownKey(LiveEntityRecord record) => - (record.ServerGuid, record.Generation); - private void RetainTeardownRecord(LiveEntityRecord record) { - var key = TeardownKey(record); - if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained) + _directory.RetainTeardown(record.Canonical); + if (_teardownRecords.TryGetValue( + record.Canonical, + out LiveEntityRecord? retained) && !ReferenceEquals(retained, record)) { throw new InvalidOperationException( $"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Generation}."); } - _teardownRecords[key] = record; + _teardownRecords[record.Canonical] = record; if (record.WorldEntity is { } entity && _visibleWorldEntitiesByGuid.TryGetValue( record.ServerGuid, @@ -2713,45 +2744,36 @@ public sealed class LiveEntityRuntime private void ReleaseTeardownRecord(LiveEntityRecord record) { - var key = TeardownKey(record); - if (_teardownRecords.TryGetValue(key, out LiveEntityRecord? retained) + if (_teardownRecords.TryGetValue( + record.Canonical, + out LiveEntityRecord? retained) && ReferenceEquals(retained, record)) { - _teardownRecords.Remove(key); + _teardownRecords.Remove(record.Canonical); } + _directory.ReleaseTeardown(record.Canonical); CompleteSessionClearIfConverged(); } - private bool HasPendingTeardown(uint serverGuid) - { - foreach ((uint Guid, ushort Generation) key in _teardownRecords.Keys) - { - if (key.Guid == serverGuid) - return true; - } - return false; - } + private bool HasPendingTeardown(uint serverGuid) => + _directory.HasPendingTeardown(serverGuid); private void CompleteSessionClearIfConverged() { if (!_sessionClearPendingFinalization - || _recordsByGuid.Count != 0 - || _teardownRecords.Count != 0) + || !_directory.CompleteSessionClearIfConverged()) { return; } _materializedWorldEntitiesByGuid.Clear(); _visibleWorldEntitiesByGuid.Clear(); - _guidByLocalId.Clear(); _animationsByLocalId.Clear(); _remoteMotionByGuid.Clear(); _spatialAnimationsByLocalId.Clear(); _spatialRemoteMotionByGuid.Clear(); _spatialProjectilesByGuid.Clear(); _spatialRootObjectsByGuid.Clear(); - ParentAttachments.Clear(); - _inbound.Clear(); _spatial.ClearLiveEntityLifetimeState(); _sessionClearPendingFinalization = false; } @@ -2815,7 +2837,7 @@ public sealed class LiveEntityRuntime if (record.WorldEntity is { } finalizedEntity) { - _guidByLocalId.Remove(finalizedEntity.Id); + _directory.ReleaseLocalId(record.Canonical); if (_materializedWorldEntitiesByGuid.TryGetValue( record.ServerGuid, out WorldEntity? materialized) @@ -2864,4 +2886,80 @@ public sealed class LiveEntityRuntime record.TeardownInProgress = false; } } + + /// + /// Dictionary-shaped compatibility view used only while J3.2 migrates the + /// large App projection surface. RuntimeEntityDirectory remains the sole + /// GUID/incarnation authority; this type stores sidecars by canonical + /// record reference and cannot resolve a GUID independently. + /// + private sealed class ActiveRecordView + { + private readonly RuntimeEntityDirectory _directory; + private readonly Dictionary _sidecars; + + public ActiveRecordView( + RuntimeEntityDirectory directory, + Dictionary sidecars) + { + _directory = directory; + _sidecars = sidecars; + } + + public int Count => _directory.Count; + public IReadOnlyCollection Values => _sidecars.Values; + + public bool TryGetValue(uint guid, out LiveEntityRecord record) + { + if (_directory.TryGetActive(guid, out RuntimeEntityRecord canonical) + && _sidecars.TryGetValue(canonical, out LiveEntityRecord? sidecar)) + { + record = sidecar; + return true; + } + + record = null!; + return false; + } + + public bool ContainsKey(uint guid) => + _directory.TryGetActive(guid, out RuntimeEntityRecord canonical) + && _sidecars.ContainsKey(canonical); + + public LiveEntityRecord? GetValueOrDefault(uint guid) => + TryGetValue(guid, out LiveEntityRecord record) ? record : null; + + public void Add(uint guid, LiveEntityRecord record) + { + if (record.ServerGuid != guid + || !_directory.TryGetActive(guid, out RuntimeEntityRecord canonical) + || !ReferenceEquals(canonical, record.Canonical)) + { + throw new InvalidOperationException( + $"App projection sidecar 0x{record.ServerGuid:X8} does not match the canonical Runtime identity 0x{guid:X8}."); + } + + _sidecars.Add(canonical, record); + } + + public bool Remove(uint guid) => Remove(guid, out _); + + public bool Remove(uint guid, out LiveEntityRecord? record) + { + if (!_directory.RemoveActive(guid, out RuntimeEntityRecord? canonical) + || canonical is null) + { + record = null; + return false; + } + + if (!_sidecars.Remove(canonical, out record)) + { + throw new InvalidOperationException( + $"Canonical Runtime identity 0x{guid:X8} had no App projection sidecar."); + } + + return true; + } + } } diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs new file mode 100644 index 00000000..41501a40 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -0,0 +1,428 @@ +using AcDream.Core.Net; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// The single update-thread authority for live server GUIDs, incarnations, +/// accepted wire snapshots, teardown tombstones, and runtime-local identity. +/// It owns no graphical state and can run without loading App or a backend. +/// +public sealed class RuntimeEntityDirectory +{ + public const uint FirstLocalEntityId = 1_000_000u; + public const uint LastLocalEntityId = 0x3FFF_FFFFu; + + private readonly InboundPhysicsStateController _inbound = new(); + private readonly Dictionary _activeByGuid = new(); + private readonly Dictionary<(uint Guid, ushort Incarnation), RuntimeEntityRecord> + _teardownByIncarnation = new(); + private readonly Dictionary _byLocalId = new(); + private readonly Dictionary _lifetimeMutationByGuid = new(); + private uint _nextLocalEntityId; + + public RuntimeEntityDirectory(uint firstLocalEntityId = FirstLocalEntityId) + { + if (firstLocalEntityId is < FirstLocalEntityId or > LastLocalEntityId) + { + throw new ArgumentOutOfRangeException( + nameof(firstLocalEntityId), + $"Live entity ids must stay in 0x{FirstLocalEntityId:X8}..0x{LastLocalEntityId:X8}."); + } + + _nextLocalEntityId = firstLocalEntityId; + } + + public int Count => _activeByGuid.Count; + public int PendingTeardownCount => _teardownByIncarnation.Count; + public int ClaimedLocalIdCount => _byLocalId.Count; + public ulong SessionLifetimeVersion { get; private set; } + public IReadOnlyCollection ActiveRecords => _activeByGuid.Values; + public IReadOnlyCollection TeardownRecords => + _teardownByIncarnation.Values; + public IReadOnlyDictionary Snapshots => _inbound.Snapshots; + public ParentAttachmentState ParentAttachments { get; } = new(); + + public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming) => + _inbound.AcceptCreate(incoming); + + public bool TryDelete( + AcDream.Core.Net.Messages.DeleteObject.Parsed delete, + bool isLocalPlayer) => + _inbound.TryDelete(delete, isLocalPlayer); + + public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) => + _inbound.TryGetSnapshot(guid, out spawn); + + public bool TryGetActive(uint guid, out RuntimeEntityRecord record) => + _activeByGuid.TryGetValue(guid, out record!); + + public bool IsCurrent(RuntimeEntityRecord record) => + _activeByGuid.TryGetValue(record.ServerGuid, out RuntimeEntityRecord? current) + && ReferenceEquals(current, record); + + public bool TryGetByLocalId(uint localEntityId, out RuntimeEntityRecord record) => + _byLocalId.TryGetValue(localEntityId, out record!); + + public bool TryGetTeardown( + uint guid, + ushort incarnation, + out RuntimeEntityRecord record) => + _teardownByIncarnation.TryGetValue((guid, incarnation), out record!); + + public RuntimeEntityRecord AddActive(WorldSession.EntitySpawn snapshot) + { + if (_activeByGuid.ContainsKey(snapshot.Guid)) + { + throw new InvalidOperationException( + $"Live entity 0x{snapshot.Guid:X8} already has an active incarnation."); + } + + var record = new RuntimeEntityRecord(snapshot); + _activeByGuid.Add(snapshot.Guid, record); + return record; + } + + public bool RemoveActive(uint guid, out RuntimeEntityRecord? record) => + _activeByGuid.Remove(guid, out record); + + public bool RemoveActive(RuntimeEntityRecord expected) + { + if (!IsCurrent(expected)) + return false; + return _activeByGuid.Remove(expected.ServerGuid); + } + + public void RetainTeardown(RuntimeEntityRecord record) + { + var key = (record.ServerGuid, record.Incarnation); + if (_teardownByIncarnation.TryGetValue( + key, + out RuntimeEntityRecord? retained) + && !ReferenceEquals(retained, record)) + { + throw new InvalidOperationException( + $"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Incarnation}."); + } + + _teardownByIncarnation[key] = record; + } + + public void ReleaseTeardown(RuntimeEntityRecord record) + { + var key = (record.ServerGuid, record.Incarnation); + if (_teardownByIncarnation.TryGetValue( + key, + out RuntimeEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + _teardownByIncarnation.Remove(key); + } + } + + public bool HasPendingTeardown(uint serverGuid) + { + foreach ((uint Guid, ushort Incarnation) key in _teardownByIncarnation.Keys) + { + if (key.Guid == serverGuid) + return true; + } + + return false; + } + + public uint ClaimLocalId(RuntimeEntityRecord record) + { + if (!IsKnown(record)) + { + throw new InvalidOperationException( + "A local id can only be claimed for an active or retained incarnation."); + } + + if (record.LocalEntityId is { } existing) + return existing; + + uint start = _nextLocalEntityId; + do + { + uint candidate = _nextLocalEntityId; + _nextLocalEntityId = candidate == LastLocalEntityId + ? FirstLocalEntityId + : candidate + 1u; + if (_byLocalId.ContainsKey(candidate)) + continue; + + _byLocalId.Add(candidate, record); + record.LocalEntityId = candidate; + return candidate; + } + while (_nextLocalEntityId != start); + + throw new InvalidOperationException("The live entity id namespace is exhausted."); + } + + public bool ReleaseLocalId(RuntimeEntityRecord record) + { + if (record.LocalEntityId is not { } localId) + return false; + if (_byLocalId.TryGetValue(localId, out RuntimeEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + _byLocalId.Remove(localId); + } + + record.LocalEntityId = null; + return true; + } + + public ulong AdvanceLifetimeMutation(uint serverGuid) + { + ulong next = _lifetimeMutationByGuid.GetValueOrDefault(serverGuid) + 1UL; + _lifetimeMutationByGuid[serverGuid] = next; + return next; + } + + public ulong CurrentLifetimeMutation(uint serverGuid) => + _lifetimeMutationByGuid.GetValueOrDefault(serverGuid); + + public void BeginSessionClear() + { + SessionLifetimeVersion++; + _lifetimeMutationByGuid.Clear(); + ParentAttachments.Clear(); + _inbound.Clear(); + } + + public bool CompleteSessionClearIfConverged() + { + if (_activeByGuid.Count != 0 || _teardownByIncarnation.Count != 0) + return false; + + _byLocalId.Clear(); + ParentAttachments.Clear(); + _inbound.Clear(); + return true; + } + + public void RefreshSnapshot( + RuntimeEntityRecord record, + WorldSession.EntitySpawn accepted, + bool refreshPosition = false) + { + EnsureKnown(record); + record.Snapshot = accepted; + record.RefreshDerivedState(refreshPosition); + } + + public void AdvanceCreateAuthority(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceCreateAuthority(); + } + + public void AdvancePositionAuthority(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvancePositionAuthority(); + } + + public void AdvanceVectorAuthority(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceVectorAuthority(); + } + + public void AdvanceMovementAuthority(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceMovementAuthority(); + } + + public void AdvanceObjDescAuthority(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.AdvanceObjDescAuthority(); + } + + public RetailPhysicsStateTransition ApplyRawPhysicsState( + RuntimeEntityRecord record, + uint rawState) + { + EnsureKnown(record); + return record.ApplyRawPhysicsState(rawState); + } + + public bool TryDequeueStateTransition( + RuntimeEntityRecord record, + out RetailPhysicsStateTransition transition) + { + EnsureKnown(record); + return record.TryDequeueStateTransition(out transition); + } + + public void SetChildNoDraw(RuntimeEntityRecord record, bool noDraw) + { + EnsureKnown(record); + record.SetChildNoDraw(noDraw); + } + + public void SuspendObjectClock(RuntimeEntityRecord record) + { + EnsureKnown(record); + record.SuspendObjectClock(); + } + + public void ResetObjectClockForEnterWorld(RuntimeEntityRecord record, bool isStatic) + { + EnsureKnown(record); + record.ResetObjectClockForEnterWorld(isStatic); + } + + public void SetFullCell( + RuntimeEntityRecord record, + uint fullCellId, + uint canonicalLandblockId) + { + EnsureKnown(record); + record.FullCellId = fullCellId; + record.CanonicalLandblockId = canonicalLandblockId; + } + + public void SetFinalPhysicsState( + RuntimeEntityRecord record, + PhysicsStateFlags state) + { + EnsureKnown(record); + record.FinalPhysicsState = state; + } + + public void SetHasPartArray(RuntimeEntityRecord record, bool value) + { + EnsureKnown(record); + record.HasPartArray = value; + } + + public void SetPhysicsBody( + RuntimeEntityRecord record, + AcDream.Core.Physics.PhysicsBody? body) + { + EnsureKnown(record); + record.PhysicsBody = body; + } + + public void SetPhysicsBodyAcquisitionInProgress( + RuntimeEntityRecord record, + bool value) + { + EnsureKnown(record); + record.PhysicsBodyAcquisitionInProgress = value; + } + + public void SetPhysicsHost( + RuntimeEntityRecord record, + AcDream.Core.Physics.Motion.IPhysicsObjHost? host) + { + EnsureKnown(record); + record.PhysicsHost = host; + } + + public void SetDeleteAcceptedForTeardown( + RuntimeEntityRecord record, + bool value) + { + EnsureKnown(record); + record.DeleteAcceptedForTeardown = value; + } + + public bool TryApplyObjDesc( + AcDream.Core.Net.Messages.ObjDescEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyObjDesc(update, out accepted); + + public bool TryApplyPickup( + AcDream.Core.Net.Messages.PickupEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyPickup(update, out accepted); + + public bool TryApplyCreateParent( + CreateParentUpdate update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyCreateParent(update, out accepted); + + public bool TryApplyParent( + AcDream.Core.Net.Messages.ParentEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyParent(update, out accepted); + + public bool TryCommitParent( + uint childGuid, + uint parentGuid, + uint parentLocation, + uint placementId, + ushort positionSequence, + out WorldSession.EntitySpawn accepted) => + _inbound.TryCommitParent( + childGuid, + parentGuid, + parentLocation, + placementId, + positionSequence, + out accepted); + + public bool TryApplyMotion( + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryApplyMotion(update, retainPayload, out accepted, out timestamps); + + public bool TryApplyVector( + AcDream.Core.Net.Messages.VectorUpdate.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyVector(update, out accepted); + + public bool TryApplyState( + AcDream.Core.Net.Messages.SetState.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.TryApplyState(update, out accepted); + + public bool TryApplyPosition( + WorldSession.EntityPositionUpdate update, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out AcceptedPhysicsTimestamps timestamps) => + _inbound.TryApplyPosition( + update, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + out disposition, + out accepted, + out timestamps); + + public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) => + _inbound.IsFreshTeleportStart(guid, teleportSequence); + + private bool IsKnown(RuntimeEntityRecord record) + { + if (IsCurrent(record)) + return true; + return _teardownByIncarnation.TryGetValue( + (record.ServerGuid, record.Incarnation), + out RuntimeEntityRecord? retained) + && ReferenceEquals(retained, record); + } + + private void EnsureKnown(RuntimeEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + if (!IsKnown(record)) + { + throw new InvalidOperationException( + $"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} is no longer owned by the directory."); + } + } +} diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs new file mode 100644 index 00000000..d4c60879 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeEntityRecord.cs @@ -0,0 +1,160 @@ +using AcDream.Core.Net; +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Entities; + +/// +/// Stable identity issued by the canonical runtime directory. The local id is +/// claimed when a graphical host first materializes the incarnation; a +/// no-window host may claim it immediately. +/// +public readonly record struct RuntimeEntityKey( + uint LocalEntityId, + ushort Incarnation); + +/// +/// Presentation-free canonical state for one accepted server-object +/// incarnation. App projection, animation, effects, visibility, and hydration +/// state must never be stored here. +/// +public sealed class RuntimeEntityRecord +{ + private readonly Queue _pendingStateTransitions = new(); + + internal RuntimeEntityRecord(WorldSession.EntitySpawn snapshot) + { + ServerGuid = snapshot.Guid; + Snapshot = snapshot; + RefreshDerivedState(); + PositionAuthorityVersion = snapshot.Position is null ? 0UL : 1UL; + VectorAuthorityVersion = snapshot.Physics is null ? 0UL : 1UL; + VelocityAuthorityVersion = snapshot.Position is null + && snapshot.Physics is null + ? 0UL + : 1UL; + MovementAuthorityVersion = 1UL; + ObjDescAuthorityVersion = 1UL; + FinalPhysicsState = RetailPhysicsStateTransitions.ConstructorState; + ApplyRawPhysicsState(RawPhysicsState); + } + + public uint ServerGuid { get; } + public ushort Incarnation => Snapshot.InstanceSequence; + public ushort Generation => Incarnation; + public WorldSession.EntitySpawn Snapshot { get; internal set; } + public uint? LocalEntityId { get; internal set; } + public RuntimeEntityKey? Key => LocalEntityId is { } localId + ? new RuntimeEntityKey(localId, Incarnation) + : null; + public uint FullCellId { get; internal set; } + public uint CanonicalLandblockId { get; internal set; } + public uint RawPhysicsState { get; internal set; } + public PhysicsStateFlags FinalPhysicsState { get; internal set; } + + /// + /// Incarnation-stable retail CPhysicsObj::update_time owner. + /// + public RetailObjectQuantumClock ObjectClock { get; } = new(); + + public ulong ObjectClockEpoch { get; private set; } + + /// + /// True after this incarnation has successfully constructed its retail + /// CPartArray. This is a simulation/lifetime fact, not a renderer + /// object reference. + /// + public bool HasPartArray { get; internal set; } + public PhysicsBody? PhysicsBody { get; internal set; } + public bool PhysicsBodyAcquisitionInProgress { get; internal set; } + public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost { get; internal set; } + public ulong PositionAuthorityVersion { get; private set; } + public ulong StateAuthorityVersion { get; private set; } + public ulong VectorAuthorityVersion { get; private set; } + public ulong VelocityAuthorityVersion { get; private set; } + public ulong MovementAuthorityVersion { get; private set; } + public ulong ObjDescAuthorityVersion { get; private set; } + public ulong CreateIntegrationVersion { get; private set; } = 1UL; + public bool DeleteAcceptedForTeardown { get; internal set; } + + internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) => + _pendingStateTransitions.TryDequeue(out transition); + + internal void SuspendObjectClock() + { + ObjectClock.Deactivate(); + ObjectClockEpoch++; + } + + internal void ResetObjectClockForEnterWorld(bool isStatic) + { + ObjectClock.ResetForEnterWorld(isStatic); + ObjectClockEpoch++; + } + + internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) + { + StateAuthorityVersion++; + RawPhysicsState = rawState; + RetailPhysicsStateTransition transition = RetailPhysicsStateTransitions.Apply( + FinalPhysicsState, + (PhysicsStateFlags)rawState); + FinalPhysicsState = transition.FinalState; + if (PhysicsBody is not null) + PhysicsBody.State = FinalPhysicsState; + _pendingStateTransitions.Enqueue(transition); + return transition; + } + + internal void AdvancePositionAuthority() + { + PositionAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceVectorAuthority() + { + VectorAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceMovementAuthority() + { + MovementAuthorityVersion++; + VelocityAuthorityVersion++; + } + + internal void AdvanceObjDescAuthority() => ObjDescAuthorityVersion++; + + internal void AdvanceCreateAuthority() + { + PositionAuthorityVersion++; + StateAuthorityVersion++; + VectorAuthorityVersion++; + VelocityAuthorityVersion++; + MovementAuthorityVersion++; + ObjDescAuthorityVersion++; + CreateIntegrationVersion++; + } + + internal void SetChildNoDraw(bool noDraw) + { + FinalPhysicsState = noDraw + ? FinalPhysicsState | PhysicsStateFlags.NoDraw + : FinalPhysicsState & ~PhysicsStateFlags.NoDraw; + if (PhysicsBody is not null) + PhysicsBody.State = FinalPhysicsState; + } + + internal void RefreshDerivedState(bool refreshPosition = true) + { + if (refreshPosition && Snapshot.Position is { } position) + { + FullCellId = position.LandblockId; + CanonicalLandblockId = (FullCellId & 0xFFFF0000u) | 0xFFFFu; + } + + RawPhysicsState = Snapshot.Physics?.RawState + ?? Snapshot.PhysicsState + ?? 0u; + } +} diff --git a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs new file mode 100644 index 00000000..8190d4d5 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs @@ -0,0 +1,76 @@ +using System.Reflection; +using AcDream.App.World; +using AcDream.Runtime.Entities; + +namespace AcDream.App.Tests.World; + +public sealed class RuntimeEntityOwnershipTests +{ + [Fact] + public void LiveEntityRuntime_ComposesCanonicalRuntimeDirectory() + { + FieldInfo directory = typeof(LiveEntityRuntime).GetField( + "_directory", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Missing canonical entity directory."); + FieldInfo sidecars = typeof(LiveEntityRuntime).GetField( + "_activeRecords", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Missing App projection sidecar store."); + + Assert.Equal(typeof(RuntimeEntityDirectory), directory.FieldType); + Assert.Equal(typeof(RuntimeEntityRecord), sidecars.FieldType.GetGenericArguments()[0]); + Assert.Equal(typeof(LiveEntityRecord), sidecars.FieldType.GetGenericArguments()[1]); + } + + [Fact] + public void AppCompatibilityLookup_IsNotASecondGuidDictionary() + { + FieldInfo compatibilityView = typeof(LiveEntityRuntime).GetField( + "_recordsByGuid", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Missing migration compatibility view."); + + Assert.False(compatibilityView.FieldType.IsGenericType); + Assert.DoesNotContain( + compatibilityView.FieldType.GetInterfaces(), + type => type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + } + + [Fact] + public void CanonicalRuntimeRecord_HasNoProjectionOrBackendSurface() + { + string[] forbiddenPropertyNames = + [ + "WorldEntity", + "AnimationRuntime", + "RemoteMotionRuntime", + "ProjectileRuntime", + "EffectProfile", + "ResourcesRegistered", + "IsSpatiallyProjected", + "IsSpatiallyVisible", + ]; + PropertyInfo[] properties = typeof(RuntimeEntityRecord).GetProperties( + BindingFlags.Instance | BindingFlags.Public); + + Assert.DoesNotContain( + properties, + property => forbiddenPropertyNames.Contains( + property.Name, + StringComparer.Ordinal)); + Assert.DoesNotContain( + properties, + property => IsPresentationType(property.PropertyType)); + } + + private static bool IsPresentationType(Type type) + { + string? ns = type.Namespace; + return ns?.StartsWith("AcDream.App", StringComparison.Ordinal) == true + || ns?.StartsWith("AcDream.UI", StringComparison.Ordinal) == true + || ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true + || ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true; + } +} diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityDirectoryTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityDirectoryTests.cs new file mode 100644 index 00000000..19b82656 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeEntityDirectoryTests.cs @@ -0,0 +1,231 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeEntityDirectoryTests +{ + [Fact] + public void AddActive_OwnsOneCurrentIncarnationPerGuid() + { + var directory = new RuntimeEntityDirectory(); + RuntimeEntityRecord first = directory.AddActive(Spawn(0x70000001u, 1)); + + Assert.True(directory.TryGetActive(first.ServerGuid, out RuntimeEntityRecord found)); + Assert.Same(first, found); + Assert.True(directory.IsCurrent(first)); + Assert.Throws( + () => directory.AddActive(Spawn(first.ServerGuid, 2))); + } + + [Fact] + public void RemoveAndRetainTombstone_AllowsReplacementWithoutLosingExactOldIncarnation() + { + var directory = new RuntimeEntityDirectory(); + RuntimeEntityRecord old = directory.AddActive(Spawn(0x70000002u, 7)); + + Assert.True(directory.RemoveActive(old.ServerGuid, out RuntimeEntityRecord? removed)); + Assert.Same(old, removed); + directory.RetainTeardown(old); + RuntimeEntityRecord replacement = directory.AddActive(Spawn(old.ServerGuid, 8)); + + Assert.True(directory.TryGetActive(old.ServerGuid, out RuntimeEntityRecord current)); + Assert.Same(replacement, current); + Assert.True(directory.TryGetTeardown(old.ServerGuid, 7, out RuntimeEntityRecord tombstone)); + Assert.Same(old, tombstone); + Assert.False(directory.IsCurrent(old)); + Assert.True(directory.IsCurrent(replacement)); + } + + [Fact] + public void LocalIdentity_IsOwnedByDirectoryAndReverseLookupRejectsReleasedIncarnation() + { + var directory = new RuntimeEntityDirectory(); + RuntimeEntityRecord old = directory.AddActive(Spawn(0x70000003u, 1)); + uint oldLocalId = directory.ClaimLocalId(old); + Assert.True(directory.TryGetByLocalId(oldLocalId, out RuntimeEntityRecord foundOld)); + Assert.Same(old, foundOld); + + Assert.True(directory.RemoveActive(old.ServerGuid, out _)); + directory.RetainTeardown(old); + RuntimeEntityRecord replacement = directory.AddActive(Spawn(old.ServerGuid, 2)); + uint replacementLocalId = directory.ClaimLocalId(replacement); + + Assert.NotEqual(oldLocalId, replacementLocalId); + Assert.True(directory.ReleaseLocalId(old)); + Assert.False(directory.TryGetByLocalId(oldLocalId, out _)); + Assert.True(directory.TryGetByLocalId( + replacementLocalId, + out RuntimeEntityRecord foundReplacement)); + Assert.Same(replacement, foundReplacement); + } + + [Fact] + public void LocalIdentity_WrapsWithinReservedNamespaceWithoutCollision() + { + var directory = new RuntimeEntityDirectory(RuntimeEntityDirectory.LastLocalEntityId); + RuntimeEntityRecord first = directory.AddActive(Spawn(0x70000004u, 1)); + RuntimeEntityRecord second = directory.AddActive(Spawn(0x70000005u, 1)); + + Assert.Equal( + RuntimeEntityDirectory.LastLocalEntityId, + directory.ClaimLocalId(first)); + Assert.Equal( + RuntimeEntityDirectory.FirstLocalEntityId, + directory.ClaimLocalId(second)); + } + + [Fact] + public void SessionClear_AdvancesEpochAndPreservesRetryableTombstone() + { + var directory = new RuntimeEntityDirectory(); + RuntimeEntityRecord record = directory.AddActive(Spawn(0x70000006u, 3)); + ulong operation = directory.AdvanceLifetimeMutation(record.ServerGuid); + Assert.Equal(operation, directory.CurrentLifetimeMutation(record.ServerGuid)); + + Assert.True(directory.RemoveActive(record.ServerGuid, out _)); + directory.RetainTeardown(record); + directory.BeginSessionClear(); + + Assert.Equal(1UL, directory.SessionLifetimeVersion); + Assert.Equal(0UL, directory.CurrentLifetimeMutation(record.ServerGuid)); + Assert.False(directory.CompleteSessionClearIfConverged()); + Assert.True(directory.TryGetTeardown(record.ServerGuid, 3, out _)); + + directory.ReleaseTeardown(record); + Assert.True(directory.CompleteSessionClearIfConverged()); + } + + [Fact] + public void AcceptedCreateSnapshotAndAuthorityLiveOnCanonicalRecord() + { + var directory = new RuntimeEntityDirectory(); + WorldSession.EntitySpawn spawn = Spawn(0x70000007u, 4); + InboundCreateResult accepted = directory.AcceptCreate(spawn); + RuntimeEntityRecord record = directory.AddActive(accepted.Snapshot); + + ulong priorCreateVersion = record.CreateIntegrationVersion; + directory.AdvanceCreateAuthority(record); + directory.RefreshSnapshot( + record, + accepted.Snapshot with { Name = "refreshed" }, + refreshPosition: true); + + Assert.Equal("refreshed", record.Snapshot.Name); + Assert.Equal(priorCreateVersion + 1UL, record.CreateIntegrationVersion); + Assert.Equal(spawn.Position!.Value.LandblockId, record.FullCellId); + } + + [Fact] + public void DeleteGateAndIdentityRemovalRemainSeparateRetryableEdges() + { + var directory = new RuntimeEntityDirectory(); + WorldSession.EntitySpawn spawn = Spawn(0x70000008u, 9); + directory.AcceptCreate(spawn); + RuntimeEntityRecord record = directory.AddActive(spawn); + + Assert.True(directory.TryDelete( + new DeleteObject.Parsed(spawn.Guid, spawn.InstanceSequence), + isLocalPlayer: false)); + Assert.True(directory.IsCurrent(record)); + Assert.True(directory.RemoveActive(record)); + directory.RetainTeardown(record); + Assert.True(directory.TryGetTeardown( + spawn.Guid, + spawn.InstanceSequence, + out RuntimeEntityRecord retained)); + Assert.Same(record, retained); + } + + [Fact] + public void CanonicalRecord_PublicSurfaceContainsNoAppOrBackendTypes() + { + Type canonical = typeof(RuntimeEntityRecord); + Type[] exposed = canonical + .GetProperties() + .Select(property => property.PropertyType) + .Concat(canonical.GetMethods().Select(method => method.ReturnType)) + .ToArray(); + + Assert.DoesNotContain(exposed, type => + type.Namespace?.StartsWith("AcDream.App", StringComparison.Ordinal) == true + || type.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true + || type.Namespace?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true); + } + + [Fact] + public void Constructor_RejectsIdsOutsideLiveNamespace() + { + Assert.Throws( + () => new RuntimeEntityDirectory( + RuntimeEntityDirectory.FirstLocalEntityId - 1u)); + Assert.Throws( + () => new RuntimeEntityDirectory( + RuntimeEntityDirectory.LastLocalEntityId + 1u)); + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + 0x0101FFFFu, + 10f, + 20f, + 5f, + 1f, + 0f, + 0f, + 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: 0x408u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "fixture", + null, + null, + 0x09000001u, + PhysicsState: 0x408u, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +}