diff --git a/src/AcDream.App/Combat/CombatAttackTargetSource.cs b/src/AcDream.App/Combat/CombatAttackTargetSource.cs index 854698f4..b2596d39 100644 --- a/src/AcDream.App/Combat/CombatAttackTargetSource.cs +++ b/src/AcDream.App/Combat/CombatAttackTargetSource.cs @@ -73,8 +73,10 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource } (uint Guid, float DistanceSquared)? best = null; - foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities) + foreach (LiveEntityRecord record in _liveEntities.VisibleRecords) { + uint guid = record.ServerGuid; + WorldEntity entity = record.WorldEntity!; if (!IsHostileMonster(guid)) continue; diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 33ae22dc..1264f61c 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -349,7 +349,19 @@ internal sealed class LivePresentationCompositionPhase new List { new( - entity => _ = entitySpawnAdapter.OnCreate(entity), + entity => + { + if (liveEntities is null + || !liveEntities.TryGetRecordByLocalEntityId( + entity.Id, + out LiveEntityRecord record) + || record.ProjectionKey is not { } key) + { + throw new InvalidOperationException( + "Live presentation registration requires an exact Runtime projection key."); + } + _ = entitySpawnAdapter.OnCreate(key, entity); + }, entity => _ = entitySpawnAdapter.OnRemove(entity)), new( entityScriptActivator.OnCreate, @@ -688,7 +700,7 @@ internal sealed class LivePresentationCompositionPhase }); var radarSnapshotProvider = new RadarSnapshotProvider( d.Objects, - () => liveEntities.WorldEntities, + liveEntities, () => liveEntities.Snapshots, playerGuid: () => d.PlayerIdentity.ServerGuid, playerYawRadians: () => d.PlayerController.Controller?.Yaw ?? 0f, @@ -696,7 +708,6 @@ internal sealed class LivePresentationCompositionPhase selectedGuid: () => d.Selection.SelectedObjectId, coordinatesOnRadar: () => d.Settings.Gameplay.CoordinatesOnRadar, uiLocked: () => d.Settings.Gameplay.LockUI, - playerEntities: () => liveEntities.MaterializedWorldEntities, spatialQuery: () => worldState); bindings.Adopt( "radar snapshot", diff --git a/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs index cb3ed253..a11d16d2 100644 --- a/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs +++ b/src/AcDream.App/Composition/LivePresentationRuntimeBindings.cs @@ -61,7 +61,7 @@ internal sealed class LivePresentationRuntimeBindings : IDisposable public void BindProjectionRemoved( EquippedChildRenderController source, - Action handler) + Action handler) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(handler); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index da1e71d1..64c2a574 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -335,6 +335,11 @@ internal sealed class SessionPlayerCompositionPhase live.WorldAvailability, d.Selection, live.WorldState, + guid => live.LiveEntities.TryGetLocalEntityId( + guid, + out uint localEntityId) + ? localEntityId + : null, content.Audio?.Engine); var compositeWarmupSource = new CompositeWarmupEntitySource(live.WorldState); diff --git a/src/AcDream.App/Input/LocalPlayerAnimationController.cs b/src/AcDream.App/Input/LocalPlayerAnimationController.cs index c9a09d7a..539f35e4 100644 --- a/src/AcDream.App/Input/LocalPlayerAnimationController.cs +++ b/src/AcDream.App/Input/LocalPlayerAnimationController.cs @@ -39,7 +39,7 @@ internal sealed class LocalPlayerAnimationController output.Reset(); uint playerGuid = _identity.ServerGuid; - if (!_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity) + if (!_liveEntities.TryGetWorldEntity(playerGuid, out var entity) || !_animations.TryGetValue(entity.Id, out var animation) || animation.Sequencer is not { } sequencer || !_liveEntities.ShouldAdvanceRootRuntime(playerGuid) @@ -65,7 +65,7 @@ internal sealed class LocalPlayerAnimationController public void CaptureHooks() { uint playerGuid = _identity.ServerGuid; - if (_liveEntities.MaterializedWorldEntities.TryGetValue(playerGuid, out var entity) + if (_liveEntities.TryGetWorldEntity(playerGuid, out var entity) && _animations.TryGetValue(entity.Id, out var animation) && animation.Sequencer is { } sequencer) { diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index 065c3839..cc158601 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -81,7 +81,7 @@ internal sealed class LivePlayerModeAutoEntryContext public bool IsLiveInWorld => _session.IsInWorld; public bool IsPlayerEntityPresent => - _liveEntities.MaterializedWorldEntities.ContainsKey(_identity.ServerGuid); + _liveEntities.ContainsWorldEntity(_identity.ServerGuid); public bool IsPlayerControllerReady => true; diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index 2c1014e7..45e8b0df 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -214,7 +214,7 @@ internal sealed class PlayerModeController : private bool TryEnter(string loggingTag) { uint playerGuid = _identity.ServerGuid; - if (!_liveEntities.MaterializedWorldEntities.TryGetValue( + if (!_liveEntities.TryGetWorldEntity( playerGuid, out WorldEntity? playerEntity)) { diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 7fe7e363..1f6bbfbc 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -239,8 +239,10 @@ internal sealed class WorldSelectionQuery return null; ClosestCombatTarget? best = null; - foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities) + foreach (LiveEntityRecord record in _liveEntities.VisibleRecords) { + uint guid = record.ServerGuid; + WorldEntity entity = record.WorldEntity!; if (!IsHostileMonster(guid)) continue; float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position); diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index 9a18c92b..167c7e7a 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -22,11 +22,6 @@ internal sealed class LiveEntityMotionRuntimeController private readonly SelectionState _selection; private readonly LiveWorldOriginState _origin; - private IReadOnlyDictionary _entitiesByServerGuid => - _liveEntities.MaterializedWorldEntities; - private IReadOnlyDictionary _visibleEntitiesByServerGuid => - _liveEntities.WorldEntities; - public LiveEntityMotionRuntimeController( LiveEntityRuntime liveEntities, PhysicsDataCache physicsDataCache, @@ -101,7 +96,7 @@ internal sealed class LiveEntityMotionRuntimeController // (own side) and StickyManager::adjust_offset's own-radius gap term. // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with // R5-V3"). - if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEntity)) + if (!_liveEntities.TryGetWorldEntity(serverGuid, out var selfEntity)) return rm.Sink; // R5-V2: forward-declared so the MoveToManager's target seams route // into the entity's TargetManager (retail CPhysicsObj::set_target → @@ -237,7 +232,7 @@ internal sealed class LiveEntityMotionRuntimeController { Console.WriteLine( $"[autowalk-host-miss] object=0x{id:X8} " - + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " + + $"materialized={_liveEntities.ContainsWorldEntity(id)} " + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " + $"hidden={liveEntities.IsHidden(id)}"); } @@ -454,7 +449,9 @@ internal sealed class LiveEntityMotionRuntimeController string target = turnPath.TargetGuid is { } targetGuid ? $"0x{targetGuid:X8}" : "null"; bool targetVisible = turnPath.TargetGuid is { } visibleGuid - && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); + && _liveEntities.TryGetInteractionEligibleEntity( + visibleGuid, + out _); bool targetHost = turnPath.TargetGuid is { } hostGuid && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; var moveTo = movement.MoveTo; diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index ac3c63f8..9935c67c 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -65,11 +65,6 @@ internal sealed class LiveEntityNetworkUpdateController private EntityPhysicsHost? _playerHost => _playerHostSource.Host; private uint _playerServerGuid => _playerIdentity.ServerGuid; private double _physicsScriptGameTime => _gameTime.CurrentScriptTime; - private IReadOnlyDictionary _entitiesByServerGuid => - _liveEntities.MaterializedWorldEntities; - private IReadOnlyDictionary _visibleEntitiesByServerGuid => - _liveEntities.WorldEntities; - internal uint? LastLivePlayerLandblockId => _authorityGate.LastLivePlayerLandblockId; @@ -262,7 +257,7 @@ internal sealed class LiveEntityNetworkUpdateController ulong acceptedMovementVelocityAuthorityVersion = accepted.VelocityAuthorityVersion; - if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; + if (!_liveEntities.TryGetWorldEntity(update.Guid, out var entity)) return; if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) { DispatchRemoteInboundMotion( @@ -566,8 +561,14 @@ internal sealed class LiveEntityNetworkUpdateController // window from this transition. Without this, the entity // starts its run animation and is instantly interrupted. var refreshedTime = System.DateTime.UtcNow; - if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) - _remoteMovementObservations[update.Guid] = (prev.Pos, refreshedTime); + if (acceptedMotionRecord.ProjectionKey is { } motionKey + && _remoteMovementObservations.TryGetValue( + motionKey, + out var prev)) + { + _remoteMovementObservations[motionKey] = + (prev.Pos, refreshedTime); + } if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr)) dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds; } @@ -817,7 +818,7 @@ internal sealed class LiveEntityNetworkUpdateController ulong acceptedVectorAuthorityVersion, ulong acceptedVectorVelocityAuthorityVersion) { - if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; + if (!_liveEntities.ContainsWorldEntity(update.Guid)) return; if (update.Guid == _playerServerGuid) return; // local jump uses our own physics if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return; @@ -863,7 +864,7 @@ internal sealed class LiveEntityNetworkUpdateController // state-derived velocity write (adaptation note: retail's // equivalence comes from the per-tick transition-sweep order — // R6 scope). - if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent) + if (_liveEntities.TryGetWorldEntity(update.Guid, out var ent) && _animatedEntities.TryGetValue(ent.Id, out var ae) && ae.Sequencer is not null) { @@ -940,12 +941,12 @@ internal sealed class LiveEntityNetworkUpdateController if (parsed.Guid == _playerServerGuid) _playerController?.ApplyPhysicsState(record.FinalPhysicsState); - if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; + if (!_liveEntities.TryGetWorldEntity(parsed.Guid, out var entity)) return; // L.2g slice 1c (2026-05-13): the server addresses entities by // ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but // ShadowObjectRegistry's cell index is keyed by local entity.Id - // (e.g. 0x000F4245). Translate via _entitiesByServerGuid before + // (e.g. 0x000F4245). Translate through the canonical Runtime directory before // mutating the registry — otherwise the lookup misses and the // state flip silently no-ops, leaving doors blocked even though // ACE flipped the ETHEREAL bit. @@ -1035,7 +1036,7 @@ internal sealed class LiveEntityNetworkUpdateController _playerController))) return; - if (!_entitiesByServerGuid.ContainsKey(update.Guid)) + if (!_liveEntities.ContainsWorldEntity(update.Guid)) { if (!IsCurrentPositionOwner()) return; @@ -1068,7 +1069,7 @@ internal sealed class LiveEntityNetworkUpdateController } } - if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; + if (!_liveEntities.TryGetWorldEntity(update.Guid, out var entity)) return; if (!IsCurrentPositionOwner(entity)) return; _entityEffects?.MarkLiveOwnerPoseDirty(update.Guid); @@ -1223,16 +1224,20 @@ internal sealed class LiveEntityNetworkUpdateController if (update.Guid != _playerServerGuid) { var now = System.DateTime.UtcNow; - if (_remoteMovementObservations.TryGetValue(update.Guid, out var prev)) + RuntimeEntityKey positionKey = positionRecord.ProjectionKey + ?? throw new InvalidOperationException( + $"Position owner 0x{update.Guid:X8}/" + + $"{positionRecord.Generation} has no exact projection key."); + if (_remoteMovementObservations.TryGetValue(positionKey, out var prev)) { float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos); if (moveDist > 0.05f) - _remoteMovementObservations[update.Guid] = (worldPos, now); + _remoteMovementObservations[positionKey] = (worldPos, now); // else: leave old entry so "Time" = last real movement time } else { - _remoteMovementObservations[update.Guid] = (worldPos, now); + _remoteMovementObservations[positionKey] = (worldPos, now); } // Retail-faithful hard-snap on UpdatePosition. diff --git a/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs index bbfca18f..9de2190f 100644 --- a/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs +++ b/src/AcDream.App/Physics/RemoteMovementObservationTracker.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Runtime.Entities; namespace AcDream.App.Physics; @@ -9,19 +10,20 @@ namespace AcDream.App.Physics; /// internal sealed class RemoteMovementObservationTracker { - private readonly Dictionary _lastMove = new(); + private readonly Dictionary + _lastMove = []; - internal (Vector3 Pos, DateTime Time) this[uint serverGuid] + internal (Vector3 Pos, DateTime Time) this[RuntimeEntityKey key] { - set => _lastMove[serverGuid] = value; + set => _lastMove[key] = value; } internal bool TryGetValue( - uint serverGuid, + RuntimeEntityKey key, out (Vector3 Pos, DateTime Time) observation) => - _lastMove.TryGetValue(serverGuid, out observation); + _lastMove.TryGetValue(key, out observation); - internal bool Remove(uint serverGuid) => _lastMove.Remove(serverGuid); + internal bool Remove(RuntimeEntityKey key) => _lastMove.Remove(key); internal void Clear() => _lastMove.Clear(); } diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs index 2af3177a..97e0015c 100644 --- a/src/AcDream.App/Physics/RemoteTeleportController.cs +++ b/src/AcDream.App/Physics/RemoteTeleportController.cs @@ -3,6 +3,7 @@ using AcDream.App.Rendering; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime.Entities; namespace AcDream.App.Physics; @@ -32,7 +33,7 @@ internal sealed class RemoteTeleportController : IDisposable private readonly Action _completeAuthoritativePlacement; private readonly Action _beginAuthoritativePlacement; private readonly PlacementResolver _resolvePlacement; - private readonly Dictionary _pending = new(); + private readonly Dictionary _pending = new(); internal RemoteTeleportController( PhysicsEngine physics, @@ -178,9 +179,9 @@ internal sealed class RemoteTeleportController : IDisposable bool wasInContact = liveRecord.PhysicsBody.InContact; bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable; RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody); - if (_pending.TryGetValue(entity.ServerGuid, out PendingPlacement prior) - && ReferenceEquals(prior.Record, liveRecord) - && prior.Generation == generation) + RuntimeEntityKey key = RequireProjectionKey(liveRecord); + if (_pending.TryGetValue(key, out PendingPlacement prior) + && ReferenceEquals(prior.Record, liveRecord)) { wasInContact = prior.WasInContact; wasOnWalkable = prior.WasOnWalkable; @@ -219,7 +220,7 @@ internal sealed class RemoteTeleportController : IDisposable return Superseded(request); if (!placement.Ok) { - _pending.Remove(entity.ServerGuid); + _pending.Remove(key); if (!RollBackDeferredPlacement(request)) return Superseded(request); return new Result( @@ -230,24 +231,18 @@ internal sealed class RemoteTeleportController : IDisposable request.Body.Orientation); } - _pending.Remove(entity.ServerGuid); + _pending.Remove(key); return CommitResolved(request, placement); } - internal void Forget(uint serverGuid) - { - _pending.Remove(serverGuid); - } - internal void Forget(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); - if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending) - && pending.Generation == record.Generation - && (record.WorldEntity is null - || ReferenceEquals(pending.Entity, record.WorldEntity))) + if (record.ProjectionKey is { } key + && _pending.TryGetValue(key, out PendingPlacement pending) + && ReferenceEquals(pending.Record, record)) { - _pending.Remove(record.ServerGuid); + _pending.Remove(key); } } @@ -256,7 +251,10 @@ internal sealed class RemoteTeleportController : IDisposable _pending.Clear(); } - internal bool HasPending(uint serverGuid) => _pending.ContainsKey(serverGuid); + internal bool HasPending(uint serverGuid) => + _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) + && record.ProjectionKey is { } key + && _pending.ContainsKey(key); internal int PendingPlacementCount => _pending.Count; /// @@ -404,7 +402,7 @@ internal sealed class RemoteTeleportController : IDisposable request.Entity.Rotation = request.RequestedOrientation; if (!IsCurrent(request)) return false; - _pending[request.Entity.ServerGuid] = request; + _pending[RequireProjectionKey(request.Record)] = request; return true; } @@ -413,14 +411,15 @@ internal sealed class RemoteTeleportController : IDisposable if (!visible) return; - if (_pending.TryGetValue(record.ServerGuid, out PendingPlacement pending)) + RuntimeEntityKey key = RequireProjectionKey(record); + if (_pending.TryGetValue(key, out PendingPlacement pending)) { if (record.WorldEntity is null || !ReferenceEquals(record, pending.Record) || !ReferenceEquals(record.WorldEntity, pending.Entity) || record.Generation != pending.Generation) { - _pending.Remove(record.ServerGuid); + _pending.Remove(key); return; } if (record.RemoteMotionRuntime is not ILiveEntityRemotePlacementRuntime currentRemote @@ -428,7 +427,7 @@ internal sealed class RemoteTeleportController : IDisposable || !ReferenceEquals(record.PhysicsBody, pending.Body) || !ReferenceEquals(currentRemote.Body, record.PhysicsBody)) { - _pending.Remove(record.ServerGuid); + _pending.Remove(key); if (record.RemoteMotionRuntime is not null && (record.PhysicsBody is null || !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody))) @@ -441,7 +440,7 @@ internal sealed class RemoteTeleportController : IDisposable if (!ReferenceEquals(currentRemote, pending.Remote)) { pending = pending with { Remote = currentRemote }; - _pending[record.ServerGuid] = pending; + _pending[key] = pending; } if (!_liveEntities.IsCurrentPositionAuthority( pending.Record, @@ -454,7 +453,7 @@ internal sealed class RemoteTeleportController : IDisposable return; } - _pending.Remove(record.ServerGuid); + _pending.Remove(key); ResolveResult placement = Resolve(pending); if (!IsCurrent(pending)) return; @@ -588,4 +587,11 @@ internal sealed class RemoteTeleportController : IDisposable private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; + + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); } diff --git a/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs b/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs index 38a79cc3..8a8929c3 100644 --- a/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs +++ b/src/AcDream.App/Rendering/AttachmentUpdateOrder.cs @@ -7,24 +7,25 @@ namespace AcDream.App.Rendering; /// those projections only after traversal, so dictionary enumeration remains /// stable. /// -internal sealed class AttachmentUpdateOrder +internal sealed class AttachmentUpdateOrder + where TKey : struct { - private readonly HashSet _visiting = new(); - private readonly HashSet _completed = new(); - private readonly HashSet _failedSet = new(); - private readonly List _failed = new(); - private readonly List _subtree = new(); - private readonly Queue _recoveryQueue = new(); - private readonly HashSet _recoveryVisited = new(); + private readonly HashSet _visiting = new(); + private readonly HashSet _completed = new(); + private readonly HashSet _failedSet = new(); + private readonly List _failed = new(); + private readonly List _subtree = new(); + private readonly Queue _recoveryQueue = new(); + private readonly HashSet _recoveryVisited = new(); /// /// Updates the current attachment map without per-frame delegate or /// enumerator boxing. The returned list is borrowed until the next call. /// - public IReadOnlyList ForEachParentFirst( - Dictionary children, - Func parentOf, - Func update) + public IReadOnlyList ForEachParentFirst( + Dictionary children, + Func parentOf, + Func update) { ArgumentNullException.ThrowIfNull(children); ArgumentNullException.ThrowIfNull(parentOf); @@ -34,7 +35,7 @@ internal sealed class AttachmentUpdateOrder _completed.Clear(); _failedSet.Clear(); _failed.Clear(); - foreach (uint childId in children.Keys) + foreach (TKey childId in children.Keys) Visit(childId, children, parentOf, update); return _failed; } @@ -44,10 +45,10 @@ internal sealed class AttachmentUpdateOrder /// withdrawn without leaving a child rooted at a stale intermediate pose. /// The returned list is borrowed until the next call. /// - public IReadOnlyList CollectSubtreePostOrder( - Dictionary children, - uint rootId, - Func parentOf) + public IReadOnlyList CollectSubtreePostOrder( + Dictionary children, + TKey rootId, + Func parentOf) { _subtree.Clear(); _visiting.Clear(); @@ -61,9 +62,9 @@ internal sealed class AttachmentUpdateOrder /// so A→B→C recovers in one parent-first drain. /// public void RealizeDescendants( - uint parentId, - Func> childrenWaitingForParent, - Func realize) + TKey parentId, + Func> childrenWaitingForParent, + Func realize) { ArgumentNullException.ThrowIfNull(childrenWaitingForParent); ArgumentNullException.ThrowIfNull(realize); @@ -74,11 +75,11 @@ internal sealed class AttachmentUpdateOrder _recoveryVisited.Add(parentId); while (_recoveryQueue.Count > 0) { - uint currentParent = _recoveryQueue.Dequeue(); - IReadOnlyList waiting = childrenWaitingForParent(currentParent); + TKey currentParent = _recoveryQueue.Dequeue(); + IReadOnlyList waiting = childrenWaitingForParent(currentParent); for (int i = 0; i < waiting.Count; i++) { - uint childId = waiting[i]; + TKey childId = waiting[i]; if (realize(childId) && _recoveryVisited.Add(childId)) _recoveryQueue.Enqueue(childId); } @@ -86,25 +87,26 @@ internal sealed class AttachmentUpdateOrder } private void Collect( - uint rootId, - Dictionary children, - Func parentOf) + TKey rootId, + Dictionary children, + Func parentOf) { if (!_visiting.Add(rootId)) return; - foreach ((uint candidateId, TChild child) in children) + foreach ((TKey candidateId, TChild child) in children) { - if (parentOf(child) == rootId) + if (parentOf(child) is { } parent + && EqualityComparer.Default.Equals(parent, rootId)) Collect(candidateId, children, parentOf); } _subtree.Add(rootId); } private void Visit( - uint childId, - Dictionary children, - Func parentOf, - Func update) + TKey childId, + Dictionary children, + Func parentOf, + Func update) { if (_completed.Contains(childId) || !children.TryGetValue(childId, out TChild? child)) @@ -114,7 +116,7 @@ internal sealed class AttachmentUpdateOrder if (!_visiting.Add(childId)) return; - uint? parentId = parentOf(child); + TKey? parentId = parentOf(child); if (parentId is { } attachedParentId && children.ContainsKey(attachedParentId)) Visit(attachedParentId, children, parentOf, update); diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index f1ca7a18..f4e6db7b 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -11,6 +11,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Plugins; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -116,13 +117,13 @@ internal sealed class DatLiveEntityProjectionMaterializer } public bool TryMaterialize( - LiveEntityRecord expectedRecord, + RuntimeEntityRecord expectedCanonical, WorldSession.EntitySpawn canonicalSpawn, LiveProjectionPurpose purpose, ulong expectedCreateIntegrationVersion, LiveEntityAppearanceUpdateState? appearanceUpdate = null) { - ArgumentNullException.ThrowIfNull(expectedRecord); + ArgumentNullException.ThrowIfNull(expectedCanonical); if (purpose is LiveProjectionPurpose.AppearanceMutation != (appearanceUpdate is not null)) { @@ -131,13 +132,18 @@ internal sealed class DatLiveEntityProjectionMaterializer nameof(appearanceUpdate)); } if (!_runtime.IsCurrentCreateIntegration( - expectedRecord, + expectedCanonical, expectedCreateIntegrationVersion) - || canonicalSpawn.Guid != expectedRecord.ServerGuid - || canonicalSpawn.InstanceSequence != expectedRecord.Generation) + || canonicalSpawn.Guid != expectedCanonical.ServerGuid + || canonicalSpawn.InstanceSequence != expectedCanonical.Incarnation) { return false; } + _runtime.TryGetProjection( + expectedCanonical, + out LiveEntityRecord? expectedRecord); + if (appearanceUpdate is not null && expectedRecord is null) + return false; _received++; bool dumpLiveSpawns = _options.DumpLiveSpawns; @@ -186,7 +192,7 @@ internal sealed class DatLiveEntityProjectionMaterializer return false; } - expectedRecord.HasPartArray = true; + _runtime.SetHasPartArray(expectedCanonical, true); ushort? stanceOverride = canonicalSpawn.MotionState?.Stance; ushort? commandOverride = canonicalSpawn.MotionState?.ForwardCommand; var idleCycle = MotionResolver.GetIdleCycle( @@ -327,7 +333,8 @@ internal sealed class DatLiveEntityProjectionMaterializer bool supersessionRecovery = purpose is LiveProjectionPurpose.CreateSupersessionRecovery; - if (supersessionRecovery && expectedRecord.WorldEntity is not null) + if (supersessionRecovery + && expectedRecord?.WorldEntity is not null) { return LiveEntityCreateSupersessionRecovery.TryApply( _runtime, @@ -380,7 +387,7 @@ internal sealed class DatLiveEntityProjectionMaterializer if (appearanceUpdate is { } visualUpdate) { return ApplyAppearance( - expectedRecord, + expectedRecord!, canonicalSpawn, visualUpdate, setup, @@ -398,6 +405,7 @@ internal sealed class DatLiveEntityProjectionMaterializer } return MaterializeProjection( + expectedCanonical, expectedRecord, canonicalSpawn, setup, @@ -670,7 +678,8 @@ internal sealed class DatLiveEntityProjectionMaterializer } private bool MaterializeProjection( - LiveEntityRecord expectedRecord, + RuntimeEntityRecord expectedCanonical, + LiveEntityRecord? retainedRecord, WorldSession.EntitySpawn spawn, Setup setup, MotionResolver.IdleCycle? idleCycle, @@ -690,17 +699,18 @@ internal sealed class DatLiveEntityProjectionMaterializer ulong expectedCreateIntegrationVersion, bool synchronizeAnimation) { - if (!_runtime.TryGetEffectProfile(spawn.Guid, out _)) + EntityEffectProfile profile = spawn.Physics is { } physics + ? EntityEffectProfile.CreateLive(setup, physics) + : EntityEffectProfile.CreateDatStatic(setup); + if (retainedRecord is not null + && !_runtime.TryGetEffectProfile(spawn.Guid, out _)) { - EntityEffectProfile profile = spawn.Physics is { } physics - ? EntityEffectProfile.CreateLive(setup, physics) - : EntityEffectProfile.CreateDatStatic(setup); _runtime.SetEffectProfile(spawn.Guid, profile); } bool createdProjection = false; WorldEntity? entity = _runtime.MaterializeLiveEntity( - spawn.Guid, + expectedCanonical, spawn.Position!.Value.LandblockId, localId => { @@ -721,8 +731,12 @@ internal sealed class DatLiveEntityProjectionMaterializer if (bounds.TryGet(out Vector3 minimum, out Vector3 maximum)) created.SetLocalBounds(minimum, maximum); return created; - }); + }, + LiveEntityProjectionKind.World, + initializeProjection: record => record.EffectProfile = profile, + out LiveEntityRecord? expectedRecord); if (entity is null + || expectedRecord is null || !_runtime.IsCurrentCreateIntegration( expectedRecord, expectedCreateIntegrationVersion) @@ -828,7 +842,7 @@ internal sealed class DatLiveEntityProjectionMaterializer if (dumpLiveSpawns && _received % 20 == 0) { Console.WriteLine( - $"live: animated={_runtime.AnimationRuntimes.Count} " + $"live: animated={_runtime.AnimationRuntimeCount} " + $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} " + $"1frame={_singleFrame} partFrames={_missingPartFrames}"); Console.WriteLine( diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index bf168550..1cfa80f4 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -41,25 +41,34 @@ public sealed class EquippedChildRenderController : IDisposable /// Raised after an attached projection has its composed pose. public event Action? ProjectionPoseReady; /// Raised when an attached projection leaves its cell presentation. - public event Action? ProjectionRemoved; - private readonly Dictionary _attachedByChild = new(); - private readonly Dictionary _pendingUnparentByChild = new(); - private readonly Dictionary _pendingOrdinaryRemovalByRoot = new(); - private readonly Dictionary _pendingDetachedRemovalByChild = new(); - private readonly Dictionary _pendingReparentRemovalByChild = new(); - private readonly Dictionary _pendingPoseLossRemovalByChild = new(); - private readonly Dictionary _pendingOrphanRemovalByChild = new(); + public event Action? ProjectionRemoved; + private readonly Dictionary _attachedByChild = []; + private readonly Dictionary + _pendingUnparentByChild = []; + private readonly Dictionary + _pendingOrdinaryRemovalByRoot = []; + private readonly Dictionary + _pendingDetachedRemovalByChild = []; + private readonly Dictionary + _pendingReparentRemovalByChild = []; + private readonly Dictionary + _pendingPoseLossRemovalByChild = []; + private readonly Dictionary + _pendingOrphanRemovalByChild = []; private readonly List _pendingProjectionChildrenScratch = new(); - private readonly List> + private readonly List> _pendingOrdinaryRemovalScratch = new(); - private readonly List> + private readonly List> _pendingProjectionSubtreeScratch = new(); - private readonly List> + private readonly List> _pendingUnparentScratch = new(); - private readonly AttachmentUpdateOrder _updateOrder = new(); - private readonly Func _parentOfAttached; - private readonly Func _tickAttached; - private readonly Func _reconcileAttached; + private readonly AttachmentUpdateOrder + _updateOrder = new(); + private readonly AttachmentUpdateOrder _relationRecoveryOrder = + new(); + private readonly Func _parentOfAttached; + private readonly Func _tickAttached; + private readonly Func _reconcileAttached; private int _activePoseCompositionVisits; internal int LastFullPoseCompositionVisits { get; private set; } @@ -92,7 +101,7 @@ public sealed class EquippedChildRenderController : IDisposable _acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent)); _withdrawProjection = withdrawProjection ?? throw new ArgumentNullException(nameof(withdrawProjection)); - _parentOfAttached = static child => child.ParentGuid; + _parentOfAttached = static child => child.ParentRecord.ProjectionKey; _tickAttached = TickChild; _reconcileAttached = ReconcileChild; @@ -258,11 +267,11 @@ public sealed class EquippedChildRenderController : IDisposable { if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) { - _pendingUnparentByChild.Remove(childGuid); Relations.EndChildProjection(childGuid); return ChildUnparentDisposition.NotAttached; } + RuntimeEntityKey key = RequireProjectionKey(record); var pending = new PendingUnparentTransition( record, record.PositionAuthorityVersion, @@ -271,7 +280,7 @@ public sealed class EquippedChildRenderController : IDisposable restoreRootRelation: false, restoreDescendantRelations: true), continuation); - return AdvanceUnparentTransition(childGuid, pending); + return AdvanceUnparentTransition(key, pending); } /// Recompose every child after the parent's animation tick. @@ -279,7 +288,7 @@ public sealed class EquippedChildRenderController : IDisposable { RetryPendingProjectionTransitions(); _activePoseCompositionVisits = 0; - IReadOnlyList failed = _updateOrder.ForEachParentFirst( + IReadOnlyList failed = _updateOrder.ForEachParentFirst( _attachedByChild, _parentOfAttached, _tickAttached); @@ -297,7 +306,7 @@ public sealed class EquippedChildRenderController : IDisposable { RetryPendingProjectionTransitions(); _activePoseCompositionVisits = 0; - IReadOnlyList failed = _updateOrder.ForEachParentFirst( + IReadOnlyList failed = _updateOrder.ForEachParentFirst( _attachedByChild, _parentOfAttached, _reconcileAttached); @@ -313,9 +322,9 @@ public sealed class EquippedChildRenderController : IDisposable _pendingOrdinaryRemovalScratch); for (int i = 0; i < _pendingOrdinaryRemovalScratch.Count; i++) { - (uint rootGuid, PendingOrdinaryRemoval pending) = + (RuntimeEntityKey rootKey, PendingOrdinaryRemoval pending) = _pendingOrdinaryRemovalScratch[i]; - AdvanceOrdinaryRemoval(rootGuid, pending); + AdvanceOrdinaryRemoval(rootKey, pending); } CopyEntries( @@ -323,9 +332,9 @@ public sealed class EquippedChildRenderController : IDisposable _pendingProjectionSubtreeScratch); for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++) { - (uint childGuid, PendingProjectionSubtree pending) = + (RuntimeEntityKey childKey, PendingProjectionSubtree pending) = _pendingProjectionSubtreeScratch[i]; - AdvanceDetachedRemoval(childGuid, pending); + AdvanceDetachedRemoval(childKey, pending); } RetryProjectionSubtrees(_pendingReparentRemovalByChild); @@ -335,16 +344,16 @@ public sealed class EquippedChildRenderController : IDisposable CopyEntries(_pendingUnparentByChild, _pendingUnparentScratch); for (int i = 0; i < _pendingUnparentScratch.Count; i++) { - (uint childGuid, PendingUnparentTransition pending) = + (RuntimeEntityKey childKey, PendingUnparentTransition pending) = _pendingUnparentScratch[i]; if (!_liveEntities.IsCurrentRecord(pending.Record) || pending.Record.PositionAuthorityVersion != pending.PositionAuthorityVersion) { - _pendingUnparentByChild.Remove(childGuid); + _pendingUnparentByChild.Remove(childKey); continue; } - AdvanceUnparentTransition(childGuid, pending); + AdvanceUnparentTransition(childKey, pending); } Relations.CopyPendingProjectionChildrenTo(_pendingProjectionChildrenScratch); @@ -353,17 +362,17 @@ public sealed class EquippedChildRenderController : IDisposable } private static void CopyEntries( - Dictionary source, - List> destination) + Dictionary source, + List> destination) { destination.Clear(); - foreach (KeyValuePair entry in source) + foreach (KeyValuePair entry in source) destination.Add(entry); } - private bool TickChild(uint childGuid) + private bool TickChild(RuntimeEntityKey childKey) { - if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) + if (!_attachedByChild.TryGetValue(childKey, out AttachedChild? child)) return false; _activePoseCompositionVisits++; @@ -403,15 +412,15 @@ public sealed class EquippedChildRenderController : IDisposable return false; } - private bool ReconcileChild(uint childGuid) + private bool ReconcileChild(RuntimeEntityKey childKey) { - if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child) + if (!_attachedByChild.TryGetValue(childKey, out AttachedChild? child) || !TryResolveExactAttachment(child, out WorldEntity parent)) { return false; } - return ParentPresentationMatches(child, parent) || TickChild(childGuid); + return ParentPresentationMatches(child, parent) || TickChild(childKey); } private bool ParentPresentationMatches( @@ -445,7 +454,9 @@ public sealed class EquippedChildRenderController : IDisposable if (!_liveEntities.TryGetRecord(pending.ParentGuid, out LiveEntityRecord parentRecord) || parentRecord.WorldEntity is not { } parentEntity || !parentRecord.IsSpatiallyProjected - || !_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord childRecord) + || !_liveEntities.TryGetCanonical( + childGuid, + out RuntimeEntityRecord childCanonical) || !_liveEntities.TryGetSnapshot(pending.ParentGuid, out WorldSession.EntitySpawn parentSpawn) || !_liveEntities.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn childSpawn)) return false; @@ -496,22 +507,25 @@ public sealed class EquippedChildRenderController : IDisposable // hydration path sees it. Install its logical effect profile before // MaterializeLiveEntity registers runtime resources, exactly as for a // top-level projection; rebucketing/reattachment must never recreate it. - if (!_liveEntities.TryGetEffectProfile(childGuid, out _)) + var effectProfile = childSpawn.Physics is { } physics + ? Vfx.EntityEffectProfile.CreateLive(childSetup, physics) + : Vfx.EntityEffectProfile.CreateDatStatic(childSetup); + if (_liveEntities.TryGetProjection( + childCanonical, + out LiveEntityRecord? retainedChild) + && !_liveEntities.TryGetEffectProfile(childGuid, out _)) { - var effectProfile = childSpawn.Physics is { } physics - ? Vfx.EntityEffectProfile.CreateLive(childSetup, physics) - : Vfx.EntityEffectProfile.CreateDatStatic(childSetup); _liveEntities.SetEffectProfile(childGuid, effectProfile); } if (!_liveEntities.IsCurrentRecord(parentRecord) - || !_liveEntities.IsCurrentRecord(childRecord) + || !_liveEntities.IsCurrentCanonical(childCanonical) || !Relations.IsPending(pending, candidateKind)) { return false; } WorldEntity? entity = _liveEntities.MaterializeLiveEntity( - childGuid, + childCanonical, parentCellId, localId => { @@ -529,8 +543,10 @@ public sealed class EquippedChildRenderController : IDisposable created.SetIndexedPartPoses(pose.PartLocal, childPartAvailability); return created; }, - LiveEntityProjectionKind.Attached); - if (entity is null) + LiveEntityProjectionKind.Attached, + initializeProjection: record => record.EffectProfile = effectProfile, + out LiveEntityRecord? childRecord); + if (entity is null || childRecord is null) return false; if (!_liveEntities.IsCurrentRecord(parentRecord) || !_liveEntities.IsCurrentRecord(childRecord) @@ -576,8 +592,9 @@ public sealed class EquippedChildRenderController : IDisposable scale, entity); CaptureParentPresentation(attached, parentEntity); - _attachedByChild[childGuid] = attached; - _pendingUnparentByChild.Remove(childGuid); + RuntimeEntityKey childKey = RequireProjectionKey(childRecord); + _attachedByChild[childKey] = attached; + _pendingUnparentByChild.Remove(childKey); if ((parentRecord.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0) { // A child attached while its parent is already Hidden inherits @@ -809,14 +826,15 @@ public sealed class EquippedChildRenderController : IDisposable ParentAttachmentRelation relation, ParentProjectionCandidateKind candidateKind) { - if (!_liveEntities.TryGetRecord( + if (!_liveEntities.TryGetCanonical( relation.ChildGuid, - out LiveEntityRecord childRecord)) + out RuntimeEntityRecord childCanonical)) { return default; } - ulong positionAuthorityVersion = childRecord.PositionAuthorityVersion; + ulong positionAuthorityVersion = + childCanonical.PositionAuthorityVersion; if (candidateKind is ParentProjectionCandidateKind.Staged) { if (!_liveEntities.CommitStagedParent(relation, out _) @@ -833,9 +851,15 @@ public sealed class EquippedChildRenderController : IDisposable if (!Relations.IsPending(relation, candidateKind) || !_liveEntities.CommitAcceptedParentCellless( - childRecord, - positionAuthorityVersion) - || !WithdrawPriorProjection(childRecord)) + childCanonical, + positionAuthorityVersion)) + { + return default; + } + if (_liveEntities.TryGetProjection( + childCanonical, + out LiveEntityRecord? childRecord) + && !WithdrawPriorProjection(childRecord)) { return default; } @@ -850,7 +874,7 @@ public sealed class EquippedChildRenderController : IDisposable if (relation.ParentGuid == relation.ChildGuid) return ParentProjectionValidationDisposition.Rejected; if (!_liveEntities.TryGetRecord(relation.ParentGuid, out LiveEntityRecord parent) - || !_liveEntities.TryGetRecord(relation.ChildGuid, out _) + || !_liveEntities.TryGetCanonical(relation.ChildGuid, out _) || parent.WorldEntity is null || !parent.HasPartArray) { @@ -877,7 +901,7 @@ public sealed class EquippedChildRenderController : IDisposable private void RetryWaitingDescendants(uint parentGuid) { - _updateOrder.RealizeDescendants( + _relationRecoveryOrder.RealizeDescendants( parentGuid, Relations.ChildrenWaitingForParent, ResolveAndTryRealize); @@ -985,12 +1009,12 @@ public sealed class EquippedChildRenderController : IDisposable } private void AdvanceDetachedRemoval( - uint childGuid, + RuntimeEntityKey childKey, PendingProjectionSubtree pending) { AdvanceProjectionSubtree( _pendingDetachedRemovalByChild, - childGuid, + childKey, pending); } @@ -998,22 +1022,29 @@ public sealed class EquippedChildRenderController : IDisposable { if (item.CurrentlyEquippedLocation == EquipMask.None) return; - if (_attachedByChild.TryGetValue(item.ObjectId, out AttachedChild? attached) + if (!_liveEntities.TryGetRecord( + item.ObjectId, + out LiveEntityRecord record) + || record.ProjectionKey is not { } key) + { + return; + } + if (_attachedByChild.TryGetValue(key, out AttachedChild? attached) && _liveEntities.IsCurrentRecord(attached.ChildRecord) && attached.ChildRecord.IsSpatiallyProjected) { // A component-stage failure left the exact equipped projection // untouched. The authoritative rollback therefore has nothing to // reconstruct and merely cancels its retained leave-world retry. - _pendingDetachedRemovalByChild.Remove(item.ObjectId); + _pendingDetachedRemovalByChild.Remove(key); return; } if (_pendingDetachedRemovalByChild.TryGetValue( - item.ObjectId, + key, out PendingProjectionSubtree pending)) { - AdvanceDetachedRemoval(item.ObjectId, pending); - if (_pendingDetachedRemovalByChild.ContainsKey(item.ObjectId)) + AdvanceDetachedRemoval(key, pending); + if (_pendingDetachedRemovalByChild.ContainsKey(key)) return; } @@ -1032,7 +1063,11 @@ public sealed class EquippedChildRenderController : IDisposable private bool Remove(uint childGuid) { - if (!_attachedByChild.TryGetValue(childGuid, out AttachedChild? child)) + if (!_liveEntities.TryGetRecord( + childGuid, + out LiveEntityRecord record) + || record.ProjectionKey is not { } key + || !_attachedByChild.TryGetValue(key, out AttachedChild? child)) return false; if (!_liveEntities.IsCurrentRecord(child.ChildRecord)) return CommitProjectionRemoval(child); @@ -1062,13 +1097,14 @@ public sealed class EquippedChildRenderController : IDisposable private bool WithdrawPriorProjection(LiveEntityRecord childRecord) { + RuntimeEntityKey key = RequireProjectionKey(childRecord); if (_pendingReparentRemovalByChild.TryGetValue( - childRecord.ServerGuid, + key, out PendingProjectionSubtree pending)) { return AdvanceProjectionSubtree( _pendingReparentRemovalByChild, - childRecord.ServerGuid, + key, pending); } return BeginProjectionSubtreeWithdrawal( @@ -1079,7 +1115,7 @@ public sealed class EquippedChildRenderController : IDisposable } private ChildUnparentDisposition AdvanceUnparentTransition( - uint childGuid, + RuntimeEntityKey childKey, PendingUnparentTransition pending) { ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( @@ -1088,7 +1124,7 @@ public sealed class EquippedChildRenderController : IDisposable if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) { - _pendingUnparentByChild[childGuid] = pending with { Subtree = next }; + _pendingUnparentByChild[childKey] = pending with { Subtree = next }; if (outcome.Failure is not null) throw outcome.Failure; return ChildUnparentDisposition.Pending; @@ -1096,22 +1132,22 @@ public sealed class EquippedChildRenderController : IDisposable if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Superseded) { - _pendingUnparentByChild.Remove(childGuid); + _pendingUnparentByChild.Remove(childKey); if (outcome.Failure is not null) throw outcome.Failure; return ChildUnparentDisposition.Superseded; } PendingUnparentTransition awaitingContinuation = pending with { Subtree = next }; - _pendingUnparentByChild[childGuid] = awaitingContinuation; + _pendingUnparentByChild[childKey] = awaitingContinuation; if (outcome.Failure is not null) throw outcome.Failure; - Relations.EndChildProjection(childGuid); + Relations.EndChildProjection(pending.Record.ServerGuid); try { awaitingContinuation.Continuation?.Invoke(); - _pendingUnparentByChild.Remove(childGuid); + _pendingUnparentByChild.Remove(childKey); return ChildUnparentDisposition.Completed; } catch @@ -1124,9 +1160,9 @@ public sealed class EquippedChildRenderController : IDisposable && awaitingContinuation.Record.ProjectionKind is LiveEntityProjectionKind.World; if (continuationCommitted) - _pendingUnparentByChild.Remove(childGuid); + _pendingUnparentByChild.Remove(childKey); else - _pendingUnparentByChild[childGuid] = awaitingContinuation; + _pendingUnparentByChild[childKey] = awaitingContinuation; throw; } } @@ -1165,20 +1201,23 @@ public sealed class EquippedChildRenderController : IDisposable private bool CommitProjectionRemoval(AttachedChild child) { - if (!_attachedByChild.TryGetValue(child.ChildGuid, out AttachedChild? current) + RuntimeEntityKey key = RequireProjectionKey(child.ChildRecord); + if (!_attachedByChild.TryGetValue(key, out AttachedChild? current) || !ReferenceEquals(current, child)) { return false; } - _attachedByChild.Remove(child.ChildGuid); - ProjectionRemoved?.Invoke(child.Entity.Id); + _attachedByChild.Remove(key); + ProjectionRemoved?.Invoke(child.ChildRecord); return true; } - private void WithdrawForPoseLoss(uint childGuid) + private void WithdrawForPoseLoss(RuntimeEntityKey childKey) { - if (!_liveEntities.TryGetRecord(childGuid, out LiveEntityRecord record)) + if (!_liveEntities.TryGetRecord( + childKey, + out LiveEntityRecord record)) return; BeginProjectionSubtreeWithdrawal( _pendingPoseLossRemovalByChild, @@ -1191,25 +1230,28 @@ public sealed class EquippedChildRenderController : IDisposable { if (_liveEntities.TryGetRecord(guid, out LiveEntityRecord record)) { + RuntimeEntityKey key = RequireProjectionKey(record); List captures = CaptureRecordSubtreeParentFirst(record); AdvanceOrdinaryRemoval( - guid, + key, new PendingOrdinaryRemoval(record, captures, NextIndex: 0)); return; } - if (_attachedByChild.TryGetValue(guid, out AttachedChild? stale)) + AttachedChild? stale = _attachedByChild.Values.FirstOrDefault( + candidate => candidate.ChildGuid == guid); + if (stale is not null) CommitProjectionRemoval(stale); Relations.EndChildProjection(guid); } private void AdvanceOrdinaryRemoval( - uint rootGuid, + RuntimeEntityKey rootKey, PendingOrdinaryRemoval pending) { if (!_liveEntities.IsCurrentRecord(pending.RootRecord)) { - _pendingOrdinaryRemovalByRoot.Remove(rootGuid); + _pendingOrdinaryRemovalByRoot.Remove(rootKey); return; } @@ -1219,7 +1261,7 @@ public sealed class EquippedChildRenderController : IDisposable ExactProjectionWithdrawalOutcome outcome = WithdrawCaptured(captured); if (outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending) { - _pendingOrdinaryRemovalByRoot[rootGuid] = pending with { NextIndex = i }; + _pendingOrdinaryRemovalByRoot[rootKey] = pending with { NextIndex = i }; if (outcome.Failure is not null) throw outcome.Failure; return; @@ -1233,7 +1275,7 @@ public sealed class EquippedChildRenderController : IDisposable if (outcome.Failure is not null) { - _pendingOrdinaryRemovalByRoot[rootGuid] = pending with + _pendingOrdinaryRemovalByRoot[rootKey] = pending with { NextIndex = i + 1, }; @@ -1241,23 +1283,24 @@ public sealed class EquippedChildRenderController : IDisposable } } - _pendingOrdinaryRemovalByRoot.Remove(rootGuid); - Relations.EndChildProjection(rootGuid); + _pendingOrdinaryRemovalByRoot.Remove(rootKey); + Relations.EndChildProjection(pending.RootRecord.ServerGuid); } private void TearDownRecordProjections(LiveEntityRecord record) { - if (_pendingUnparentByChild.TryGetValue(record.ServerGuid, out var pending) + RuntimeEntityKey key = RequireProjectionKey(record); + if (_pendingUnparentByChild.TryGetValue(key, out var pending) && ReferenceEquals(pending.Record, record)) { - _pendingUnparentByChild.Remove(record.ServerGuid); + _pendingUnparentByChild.Remove(key); } if (_pendingOrdinaryRemovalByRoot.TryGetValue( - record.ServerGuid, + key, out PendingOrdinaryRemoval ordinary) && ReferenceEquals(ordinary.RootRecord, record)) { - _pendingOrdinaryRemovalByRoot.Remove(record.ServerGuid); + _pendingOrdinaryRemovalByRoot.Remove(key); } RemovePendingProjectionSubtree(_pendingDetachedRemovalByChild, record); RemovePendingProjectionSubtree(_pendingReparentRemovalByChild, record); @@ -1293,7 +1336,8 @@ public sealed class EquippedChildRenderController : IDisposable { var result = new List(); var visited = new HashSet(ReferenceEqualityComparer.Instance); - if (_attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? root) + if (record.ProjectionKey is { } key + && _attachedByChild.TryGetValue(key, out AttachedChild? root) && ReferenceEquals(root.ChildRecord, record)) { result.Add(Capture(root)); @@ -1327,8 +1371,10 @@ public sealed class EquippedChildRenderController : IDisposable private ExactProjectionWithdrawalOutcome WithdrawCaptured( AttachedRemovalCapture captured) { + RuntimeEntityKey key = RequireProjectionKey( + captured.Attached.ChildRecord); if (!_attachedByChild.TryGetValue( - captured.Attached.ChildGuid, + key, out AttachedChild? current) || !ReferenceEquals(current, captured.Attached)) { @@ -1369,7 +1415,9 @@ public sealed class EquippedChildRenderController : IDisposable { if (!visited.Add(record)) return; - _attachedByChild.TryGetValue(record.ServerGuid, out AttachedChild? attached); + AttachedChild? attached = null; + if (record.ProjectionKey is { } key) + _attachedByChild.TryGetValue(key, out attached); if (attached is not null && !ReferenceEquals(attached.ChildRecord, record)) attached = null; if (record.IsSpatiallyProjected || attached is not null) @@ -1396,7 +1444,7 @@ public sealed class EquippedChildRenderController : IDisposable } private bool BeginProjectionSubtreeWithdrawal( - Dictionary pendingByRoot, + Dictionary pendingByRoot, LiveEntityRecord root, bool restoreRootRelation, bool restoreDescendantRelations) @@ -1405,12 +1453,15 @@ public sealed class EquippedChildRenderController : IDisposable root, restoreRootRelation, restoreDescendantRelations); - return AdvanceProjectionSubtree(pendingByRoot, root.ServerGuid, pending); + return AdvanceProjectionSubtree( + pendingByRoot, + RequireProjectionKey(root), + pending); } private bool AdvanceProjectionSubtree( - Dictionary pendingByRoot, - uint rootGuid, + Dictionary pendingByRoot, + RuntimeEntityKey rootKey, PendingProjectionSubtree pending) { ExactProjectionWithdrawalOutcome outcome = AdvanceProjectionSubtree( @@ -1419,9 +1470,9 @@ public sealed class EquippedChildRenderController : IDisposable bool retry = outcome.Disposition is ExactProjectionWithdrawalDisposition.Pending || (outcome.Failure is not null && next.NextIndex < next.Captures.Count); if (retry) - pendingByRoot[rootGuid] = next; + pendingByRoot[rootKey] = next; else - pendingByRoot.Remove(rootGuid); + pendingByRoot.Remove(rootKey); if (outcome.Failure is not null) throw outcome.Failure; return outcome.Disposition is ExactProjectionWithdrawalDisposition.Completed @@ -1508,27 +1559,29 @@ public sealed class EquippedChildRenderController : IDisposable } private void RetryProjectionSubtrees( - Dictionary pendingByRoot) + Dictionary pendingByRoot) { CopyEntries(pendingByRoot, _pendingProjectionSubtreeScratch); for (int i = 0; i < _pendingProjectionSubtreeScratch.Count; i++) { - (uint rootGuid, PendingProjectionSubtree pending) = + (RuntimeEntityKey rootKey, PendingProjectionSubtree pending) = _pendingProjectionSubtreeScratch[i]; - AdvanceProjectionSubtree(pendingByRoot, rootGuid, pending); + AdvanceProjectionSubtree(pendingByRoot, rootKey, pending); } } private static void RemovePendingProjectionSubtree( - Dictionary pendingByRoot, + Dictionary pendingByRoot, LiveEntityRecord record) { - uint[] keys = pendingByRoot - .Where(pair => ReferenceEquals(pair.Value.RootRecord, record)) - .Select(pair => pair.Key) - .ToArray(); - for (int i = 0; i < keys.Length; i++) - pendingByRoot.Remove(keys[i]); + if (record.ProjectionKey is { } key + && pendingByRoot.TryGetValue( + key, + out PendingProjectionSubtree pending) + && ReferenceEquals(pending.RootRecord, record)) + { + pendingByRoot.Remove(key); + } } public void Clear() @@ -1606,6 +1659,13 @@ public sealed class EquippedChildRenderController : IDisposable LiveEntityRecord RootRecord, IReadOnlyList Captures, int NextIndex); + + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); } public enum ChildUnparentDisposition diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 57ef5bf4..d392e5bd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -524,15 +524,8 @@ public sealed class GameWindow : /// projection, including live objects parked in pending landblocks; /// visibility must never suppress authoritative mutation. /// - private static readonly IReadOnlyDictionary EmptyLiveEntityMap = - new Dictionary(); private static readonly IReadOnlyDictionary EmptyLiveSpawnMap = new Dictionary(); - private IReadOnlyDictionary _entitiesByServerGuid => - _liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap; - /// Visible-only view for radar, picking, status, and targets. - private IReadOnlyDictionary _visibleEntitiesByServerGuid => - _liveEntities?.WorldEntities ?? EmptyLiveEntityMap; /// /// Latest for each /// guid. Captured before the renderability gate so no-position inventory / diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs index 2ab4e4a5..26e92693 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -3,6 +3,7 @@ using AcDream.App.Rendering.Vfx; using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime.Entities; namespace AcDream.App.Rendering; @@ -84,7 +85,7 @@ internal sealed class LiveEntityAnimationPresenter } public void Present( - IReadOnlyDictionary schedules) + IReadOnlyDictionary schedules) { ArgumentNullException.ThrowIfNull(schedules); LiveEntityRuntime runtime = _liveEntities; @@ -107,7 +108,9 @@ internal sealed class LiveEntityAnimationPresenter if (!TryGetCurrent(runtime, record, entity, animation)) continue; - schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule); + LiveEntityAnimationSchedule schedule = default; + if (record.ProjectionKey is { } key) + schedules.TryGetValue(key, out schedule); bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation); IReadOnlyList? sequenceFrames = hasOrdinarySchedule ? schedule.SequenceFrames diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index 73a7ea1c..cef077f1 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -6,6 +6,7 @@ using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.Types; using RemoteMotion = AcDream.App.Physics.RemoteMotion; @@ -27,7 +28,8 @@ internal sealed class LiveEntityAnimationScheduler private readonly IEntityRootPosePublisher _rootPoses; private readonly IAnimationHookCaptureSink _animationHooks; private readonly List _rootSnapshot = new(); - private readonly Dictionary _schedules = new(); + private readonly Dictionary + _schedules = []; private readonly Frame _rootFrameScratch = new(); private readonly MotionDeltaFrame _rootDeltaScratch = new(); @@ -59,7 +61,7 @@ internal sealed class LiveEntityAnimationScheduler /// callback. The returned dictionary is reused and valid until the next /// call. /// - public IReadOnlyDictionary Tick( + public IReadOnlyDictionary Tick( float elapsedSeconds, Vector3? playerPosition, bool localHiddenPartPoseDirty, @@ -139,7 +141,11 @@ internal sealed class LiveEntityAnimationScheduler IReadOnlyList? ownedFrames = schedule.SequenceFrames is { } produced ? animation.CaptureScheduleFrames(produced) : null; - _schedules[entity.Id] = schedule with + RuntimeEntityKey key = record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/" + + $"{record.Generation} has no exact projection key."); + _schedules[key] = schedule with { SequenceFrames = ownedFrames, Record = record, diff --git a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs index ba99e608..cad94968 100644 --- a/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs +++ b/src/AcDream.App/Rendering/Scene/LiveRenderProjectionJournal.cs @@ -1,6 +1,7 @@ using AcDream.App.Update; using AcDream.App.World; using AcDream.Core.World; +using AcDream.Runtime.Entities; namespace AcDream.App.Rendering.Scene; @@ -9,7 +10,7 @@ internal interface ILiveRenderProjectionSink : IRenderProjectionSyncPhase bool OnEntityReady(LiveEntityReadyCandidate candidate); void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible); void OnProjectionPoseReady(uint serverGuid); - void OnProjectionRemoved(uint localEntityId); + void OnProjectionRemoved(LiveEntityRecord record); void OnResourceUnregister(WorldEntity entity); } @@ -25,7 +26,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink private readonly LiveEntityRuntime _runtime; private readonly RenderProjectionJournal _journal; private readonly IRenderTraversalOrderSource _traversalOrder; - private readonly Dictionary _byLocalId = []; + private readonly Dictionary _byKey = []; private readonly List _activeRootScratch = []; private readonly List _activeScratch = []; @@ -40,7 +41,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink ?? throw new ArgumentNullException(nameof(traversalOrder)); } - public int ProjectionCount => _byLocalId.Count; + public int ProjectionCount => _byKey.Count; public bool OnEntityReady(LiveEntityReadyCandidate candidate) { @@ -62,7 +63,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink ArgumentNullException.ThrowIfNull(record); if (!_runtime.IsCurrentRecord(record) || record.WorldEntity is not { } entity - || !_byLocalId.ContainsKey(entity.Id)) + || record.ProjectionKey is not { } key + || !_byKey.ContainsKey(key)) { return; } @@ -87,10 +89,12 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink Upsert(record, entity, record.IsSpatiallyVisible); } - public void OnProjectionRemoved(uint localEntityId) + public void OnProjectionRemoved(LiveEntityRecord record) { - if (_byLocalId.TryGetValue( - localEntityId, + ArgumentNullException.ThrowIfNull(record); + if (record.ProjectionKey is { } key + && _byKey.TryGetValue( + key, out TrackedProjection? tracked) && tracked.Projection.ProjectionClass is RenderProjectionClass.EquippedChild) @@ -116,11 +120,17 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink public void OnResourceUnregister(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); - if (_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked) - && ReferenceEquals(tracked.Entity, entity)) + TrackedProjection? tracked = null; + foreach (TrackedProjection candidate in _byKey.Values) { - Remove(tracked); + if (ReferenceEquals(candidate.Entity, entity)) + { + tracked = candidate; + break; + } } + if (tracked is not null) + Remove(tracked); } /// @@ -153,7 +163,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink // synchronization only refreshes children whose attachment projection // is still retained by this derived scene. _activeScratch.Clear(); - foreach (TrackedProjection tracked in _byLocalId.Values) + foreach (TrackedProjection tracked in _byKey.Values) { if (tracked.Record.IsSpatiallyProjected && tracked.Record.ProjectionKind is @@ -166,8 +176,9 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink TrackedProjection tracked = _activeScratch[i]; if (!_runtime.IsCurrentRecord(tracked.Record) || !ReferenceEquals(tracked.Record.WorldEntity, tracked.Entity) - || !_byLocalId.TryGetValue( - tracked.Entity.Id, + || tracked.Record.ProjectionKey is not { } key + || !_byKey.TryGetValue( + key, out TrackedProjection? current) || !ReferenceEquals(current, tracked)) { @@ -183,7 +194,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink public void ResetTracking() { - _byLocalId.Clear(); + _byKey.Clear(); _activeRootScratch.Clear(); _activeScratch.Clear(); } @@ -192,9 +203,11 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink uint localEntityId, out RenderProjectionRecord record) { - if (_byLocalId.TryGetValue( + if (_runtime.TryGetRecordByLocalEntityId( localEntityId, - out TrackedProjection? tracked)) + out LiveEntityRecord owner) + && owner.ProjectionKey is { } key + && _byKey.TryGetValue(key, out TrackedProjection? tracked)) { record = tracked.Projection; return true; @@ -217,11 +230,12 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink record, entity, spatiallyVisible); - if (!_byLocalId.TryGetValue(entity.Id, out TrackedProjection? tracked)) + RuntimeEntityKey key = RequireProjectionKey(record); + if (!_byKey.TryGetValue(key, out TrackedProjection? tracked)) { _journal.Register(in projected); - _byLocalId.Add( - entity.Id, + _byKey.Add( + key, new TrackedProjection(record, entity, projected)); return; } @@ -234,7 +248,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink != projected.ProjectionClass) { _journal.Register(in projected); - _byLocalId[entity.Id] = + _byKey[key] = new TrackedProjection(record, entity, projected); return; } @@ -283,8 +297,9 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink // Spatial withdrawal is not logical destruction. Retain the last // resident order while the projection is inactive so a portal/rebucket // edge does not manufacture an unrelated ordering mutation. - return _byLocalId.TryGetValue( - entity.Id, + RuntimeEntityKey key = RequireProjectionKey(record); + return _byKey.TryGetValue( + key, out TrackedProjection? retained) ? projected with { SortKey = retained.Projection.SortKey } : projected; @@ -292,7 +307,8 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink private void Remove(TrackedProjection tracked) { - if (!_byLocalId.Remove(tracked.Entity.Id)) + RuntimeEntityKey key = RequireProjectionKey(tracked.Record); + if (!_byKey.Remove(key)) return; _journal.Unregister( tracked.Projection.Id, @@ -302,6 +318,13 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink private static uint Canonicalize(uint cellOrLandblockId) => (cellOrLandblockId & 0xFFFF0000u) | 0xFFFFu; + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); + private sealed class TrackedProjection( LiveEntityRecord record, WorldEntity entity, diff --git a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs index e8a1cd5b..ca253710 100644 --- a/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/TerrainDrawDiagnosticsController.cs @@ -68,7 +68,7 @@ internal sealed class RuntimeFramePipelineDiagnosticFactsSource : _streaming?.DeferredApplyBacklog ?? 0, _streaming?.FullWindowRetirementCount ?? 0, _streaming?.LastFullWindowRetirementLandblockCount ?? 0, - _liveEntities.MaterializedWorldEntities.Count, + _liveEntities.MaterializedCount, _liveEntities.Snapshots.Count, _world.Entities.Count); } diff --git a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs index 982a66e9..1d42cb5d 100644 --- a/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs +++ b/src/AcDream.App/Rendering/Vfx/EntityEffectController.cs @@ -4,6 +4,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.Types; namespace AcDream.App.Rendering.Vfx; @@ -33,16 +34,17 @@ public sealed class EntityEffectController : IAnimationHookSink, private readonly Func _parentOfAttachedChild; private readonly Action _ownerUnregistered; private readonly Action _ownerSoundTableChanged; - private readonly Dictionary _profilesByLocalId = new(); - private readonly Dictionary _readyGenerationByServerGuid = new(); + private readonly Dictionary _liveProfiles = []; + private readonly HashSet _readyLiveOwners = []; private readonly Dictionary> _pendingByServerGuid = new(); private readonly Dictionary _staticOwners = new(); + private readonly Dictionary _staticProfiles = new(); private readonly HashSet _syntheticOwners = new(); private readonly HashSet _dirtyLiveOwners = new(ReferenceEqualityComparer.Instance); private readonly List _dirtyLiveOwnerOrder = []; private readonly List _dirtyLiveOwnerSnapshot = []; - private readonly List _readyGuidSnapshot = []; + private readonly List _readyKeySnapshot = []; private uint _posePublishLocalId; private Action? _diagnosticSink = Console.WriteLine; @@ -76,7 +78,7 @@ public sealed class EntityEffectController : IAnimationHookSink, } public int PendingPacketCount => _pendingByServerGuid.Values.Sum(queue => queue.Count); - public int ReadyOwnerCount => _profilesByLocalId.Count; + public int ReadyOwnerCount => _liveProfiles.Count + _staticProfiles.Count; internal int LastPoseRefreshOwnerVisitCount { get; private set; } public void HandleDirect(PlayPhysicsScript message) @@ -135,8 +137,9 @@ public sealed class EntityEffectController : IAnimationHookSink, return false; } - _readyGenerationByServerGuid[serverGuid] = record.Generation; - _profilesByLocalId[entity.Id] = profile; + RuntimeEntityKey key = RequireProjectionKey(record); + _readyLiveOwners.Add(key); + _liveProfiles[key] = profile; _runner.SetOwnerAnchor(entity.Id, entity.Position); _ownerSoundTableChanged(entity.Id, profile.CurrentSoundTableDid); return true; @@ -166,7 +169,7 @@ public sealed class EntityEffectController : IAnimationHookSink, return false; } - _profilesByLocalId[localId] = profile; + _liveProfiles[RequireProjectionKey(record)] = profile; _ownerSoundTableChanged(localId, profile.CurrentSoundTableDid); return true; } @@ -181,7 +184,7 @@ public sealed class EntityEffectController : IAnimationHookSink, if (ownerLocalId == 0) return; _staticOwners[ownerLocalId] = entity; - _profilesByLocalId[ownerLocalId] = profile; + _staticProfiles[ownerLocalId] = profile; _runner.SetOwnerAnchor(ownerLocalId, entity.Position); _ownerSoundTableChanged(ownerLocalId, profile.CurrentSoundTableDid); } @@ -190,7 +193,7 @@ public sealed class EntityEffectController : IAnimationHookSink, { if (!_staticOwners.Remove(localId)) return; - _profilesByLocalId.Remove(localId); + _staticProfiles.Remove(localId); _runner.StopAllForEntity(localId); _ownerUnregistered(localId); } @@ -212,16 +215,13 @@ public sealed class EntityEffectController : IAnimationHookSink, { _pendingByServerGuid.Remove(record.ServerGuid); } - if (_readyGenerationByServerGuid.TryGetValue( - record.ServerGuid, - out ushort readyGeneration) - && readyGeneration == record.Generation) + if (record.ProjectionKey is { } key) { - _readyGenerationByServerGuid.Remove(record.ServerGuid); + _readyLiveOwners.Remove(key); + _liveProfiles.Remove(key); } if (record.LocalEntityId is not { } localId) return; - _profilesByLocalId.Remove(localId); _runner.StopAllForEntity(localId); _ownerUnregistered(localId); } @@ -232,18 +232,15 @@ public sealed class EntityEffectController : IAnimationHookSink, public void ClearNetworkState() { - _readyGuidSnapshot.Clear(); - _readyGuidSnapshot.AddRange(_readyGenerationByServerGuid.Keys); - foreach (uint serverGuid in _readyGuidSnapshot) + _readyKeySnapshot.Clear(); + _readyKeySnapshot.AddRange(_readyLiveOwners); + foreach (RuntimeEntityKey key in _readyKeySnapshot) { - if (_liveEntities.TryGetLocalEntityId(serverGuid, out uint localId)) - { - _profilesByLocalId.Remove(localId); - _runner.StopAllForEntity(localId); - _ownerUnregistered(localId); - } + _runner.StopAllForEntity(key.LocalEntityId); + _ownerUnregistered(key.LocalEntityId); } - _readyGenerationByServerGuid.Clear(); + _readyLiveOwners.Clear(); + _liveProfiles.Clear(); _pendingByServerGuid.Clear(); _dirtyLiveOwners.Clear(); _dirtyLiveOwnerOrder.Clear(); @@ -331,7 +328,7 @@ public sealed class EntityEffectController : IAnimationHookSink, uint rawScriptType, float intensity) { - if (!_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile) + if (!TryGetProfile(ownerLocalId, out EntityEffectProfile? profile) || profile.CurrentPhysicsScriptTableDid is not { } tableDid) { DiagnosticSink?.Invoke( @@ -360,7 +357,7 @@ public sealed class EntityEffectController : IAnimationHookSink, public bool PlayDefault(uint ownerLocalId) { if (!CanStartOwner(ownerLocalId) - || !_profilesByLocalId.TryGetValue(ownerLocalId, out EntityEffectProfile? profile)) + || !TryGetProfile(ownerLocalId, out EntityEffectProfile? profile)) return false; return PlayTyped( ownerLocalId, @@ -433,8 +430,9 @@ public sealed class EntityEffectController : IAnimationHookSink, private bool TryGetLiveRoot(uint ownerLocalId, out LiveEntityRecord record) { - if (_liveEntities.TryGetServerGuid(ownerLocalId, out uint serverGuid) - && _liveEntities.TryGetRecord(serverGuid, out record!)) + if (_liveEntities.TryGetRecordByLocalEntityId( + ownerLocalId, + out record!)) { return true; } @@ -444,12 +442,12 @@ public sealed class EntityEffectController : IAnimationHookSink, private bool TryGetReadyLocalId(uint serverGuid, out uint localId) { - if (_readyGenerationByServerGuid.TryGetValue(serverGuid, out ushort readyGeneration) - && _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - && record.Generation == readyGeneration - && _liveEntities.TryGetLocalEntityId(serverGuid, out localId) - && _profilesByLocalId.ContainsKey(localId)) + if (_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) + && record.ProjectionKey is { } key + && _readyLiveOwners.Contains(key) + && _liveProfiles.ContainsKey(key)) { + localId = key.LocalEntityId; return true; } @@ -461,8 +459,9 @@ public sealed class EntityEffectController : IAnimationHookSink, { if (_posePublishLocalId == localId) return; - if (_liveEntities.TryGetServerGuid(localId, out uint serverGuid) - && _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) + if (_liveEntities.TryGetRecordByLocalEntityId( + localId, + out LiveEntityRecord record)) { MarkLiveOwnerPoseDirty(record); } @@ -476,10 +475,8 @@ public sealed class EntityEffectController : IAnimationHookSink, private void MarkLiveOwnerPoseDirty(LiveEntityRecord record) { - if (!_readyGenerationByServerGuid.TryGetValue( - record.ServerGuid, - out ushort readyGeneration) - || readyGeneration != record.Generation + if (record.ProjectionKey is not { } key + || !_readyLiveOwners.Contains(key) || !_dirtyLiveOwners.Add(record)) { return; @@ -550,6 +547,29 @@ public sealed class EntityEffectController : IAnimationHookSink, PlayTyped(localId, effect.RawScriptType, effect.Intensity); } + private bool TryGetProfile( + uint ownerLocalId, + out EntityEffectProfile profile) + { + if (_liveEntities.TryGetRecordByLocalEntityId( + ownerLocalId, + out LiveEntityRecord record) + && record.ProjectionKey is { } key + && _liveProfiles.TryGetValue(key, out profile!)) + { + return true; + } + + return _staticProfiles.TryGetValue(ownerLocalId, out profile!); + } + + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); + private enum PendingEffectKind { Direct, diff --git a/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs index 0ec9e730..97a74b7e 100644 --- a/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs +++ b/src/AcDream.App/Rendering/Vfx/LiveEntityLightController.cs @@ -1,6 +1,7 @@ using AcDream.App.World; using AcDream.Core.Lighting; using AcDream.Core.Physics; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; namespace AcDream.App.Rendering.Vfx; @@ -22,8 +23,8 @@ public sealed class LiveEntityLightController : IDisposable private readonly EntityEffectPoseRegistry _poses; private readonly LightingHookSink _lighting; private readonly Func _loadSetup; - private readonly Dictionary _serverGuidByOwner = new(); - private readonly HashSet _presentOwners = new(); + private readonly HashSet _trackedOwners = []; + private readonly HashSet _presentOwners = []; public LiveEntityLightController( LiveEntityRuntime liveEntities, @@ -49,8 +50,9 @@ public sealed class LiveEntityLightController : IDisposable return false; } - _serverGuidByOwner[entity.Id] = serverGuid; - _presentOwners.Add(entity.Id); + RuntimeEntityKey key = RequireProjectionKey(record); + _trackedOwners.Add(key); + _presentOwners.Add(key); _lighting.UnregisterOwner(entity.Id, forgetState: false); _lighting.InitializeOwnerLighting( entity.Id, @@ -87,7 +89,13 @@ public sealed class LiveEntityLightController : IDisposable /// Withdraw cell-scoped presentation but retain logical light state. public void Unregister(uint ownerLocalId) { - _presentOwners.Remove(ownerLocalId); + if (_liveEntities.TryGetRecordByLocalEntityId( + ownerLocalId, + out LiveEntityRecord record) + && record.ProjectionKey is { } key) + { + _presentOwners.Remove(key); + } _lighting.UnregisterOwner(ownerLocalId, forgetState: false); } @@ -105,16 +113,20 @@ public sealed class LiveEntityLightController : IDisposable } /// End logical ownership after leave-world presentation teardown. - public void Forget(uint ownerLocalId) + public void Forget(LiveEntityRecord record) { - _presentOwners.Remove(ownerLocalId); - _serverGuidByOwner.Remove(ownerLocalId); - _lighting.UnregisterOwner(ownerLocalId); + ArgumentNullException.ThrowIfNull(record); + if (record.ProjectionKey is not { } key) + return; + + _presentOwners.Remove(key); + _trackedOwners.Remove(key); + _lighting.UnregisterOwner(key.LocalEntityId); } public void Refresh() => _lighting.RefreshAttachedLights(); - internal int TrackedOwnerCount => _serverGuidByOwner.Count; + internal int TrackedOwnerCount => _trackedOwners.Count; internal int PresentedOwnerCount => _presentOwners.Count; /// @@ -127,7 +139,8 @@ public sealed class LiveEntityLightController : IDisposable if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) || record.ProjectionKind is not LiveEntityProjectionKind.Attached || record.WorldEntity is not { } entity - || _presentOwners.Contains(entity.Id)) + || record.ProjectionKey is not { } key + || _presentOwners.Contains(key)) { return; } @@ -136,8 +149,11 @@ public sealed class LiveEntityLightController : IDisposable private void OnOwnerLightingChanged(uint ownerLocalId, bool enabled) { - if (!_presentOwners.Contains(ownerLocalId) - || !_serverGuidByOwner.TryGetValue(ownerLocalId, out uint serverGuid)) + if (!_liveEntities.TryGetRecordByLocalEntityId( + ownerLocalId, + out LiveEntityRecord record) + || record.ProjectionKey is not { } key + || !_presentOwners.Contains(key)) { return; } @@ -148,15 +164,17 @@ public sealed class LiveEntityLightController : IDisposable return; } - Register(serverGuid); + Register(record.ServerGuid); } public void Dispose() { _lighting.OwnerLightingChanged -= OnOwnerLightingChanged; _liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; - foreach (uint ownerId in _serverGuidByOwner.Keys.ToArray()) - Forget(ownerId); + foreach (RuntimeEntityKey key in _trackedOwners) + _lighting.UnregisterOwner(key.LocalEntityId); + _trackedOwners.Clear(); + _presentOwners.Clear(); } private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) @@ -176,4 +194,11 @@ public sealed class LiveEntityLightController : IDisposable else Unregister(entity.Id); } + + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); } diff --git a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs index 6d7bd377..1caaa343 100644 --- a/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime.Entities; namespace AcDream.App.Rendering.Wb; @@ -48,18 +49,20 @@ public sealed class EntitySpawnAdapter private readonly Func _sequencerFactory; private readonly IWbMeshAdapter? _meshAdapter; - // One logical owner per server GUID. Animated state survives projection + // One logical owner per exact Runtime identity. Animated state survives projection // suspension, while the resident bit controls the shorter GPU-presentation // lifetime. The exact WorldEntity reference makes delayed visibility edges // from a displaced GUID generation harmless. // Single-threaded: called only from the render thread (same as GpuWorldState). - private readonly Dictionary _ownersByGuid = new(); + private readonly Dictionary _ownersByKey = []; private sealed class Owner( + RuntimeEntityKey key, WorldEntity entity, AnimatedEntityState state, HashSet meshIds) { + public RuntimeEntityKey Key { get; } = key; public WorldEntity Entity { get; } = entity; public AnimatedEntityState State { get; } = state; public HashSet MeshIds { get; set; } = meshIds; @@ -137,12 +140,25 @@ public sealed class EntitySpawnAdapter /// is atlas-tier (ServerGuid == 0). /// public AnimatedEntityState? OnCreate(WorldEntity entity) + => OnCreate(new RuntimeEntityKey(entity.Id, 0), entity); + + /// + /// Creates one exact Runtime-owned presentation resource set. + /// + public AnimatedEntityState? OnCreate( + RuntimeEntityKey key, + WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); // Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0) // are handled by LandblockSpawnAdapter, not here. if (entity.ServerGuid == 0) return null; + if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id) + { + throw new InvalidOperationException( + "The exact Runtime projection key must match the WorldEntity local ID."); + } // A.5 T18: populate cached AABB so WalkEntities reads from the cache // rather than recomputing Position±5 per frame. Called here because @@ -177,8 +193,8 @@ public sealed class EntitySpawnAdapter // valid. Retirement is also completed before replacement publication: // a failed texture or mesh release therefore leaves the prior owner in // the dictionary, with its per-resource progress available to retry. - var replacementOwner = new Owner(entity, state, meshIds); - if (_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? displacedOwner)) + var replacementOwner = new Owner(key, entity, state, meshIds); + if (_ownersByKey.TryGetValue(key, out Owner? displacedOwner)) { if (displacedOwner.RemovalPending) { @@ -200,11 +216,11 @@ public sealed class EntitySpawnAdapter } } - _ownersByGuid[entity.ServerGuid] = replacementOwner; + _ownersByKey[key] = replacementOwner; } else { - _ownersByGuid.Add(entity.ServerGuid, replacementOwner); + _ownersByKey.Add(key, replacementOwner); } return state; @@ -223,8 +239,7 @@ public sealed class EntitySpawnAdapter public bool SetPresentationResident(WorldEntity entity, bool resident) { ArgumentNullException.ThrowIfNull(entity); - if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner) - || !ReferenceEquals(owner.Entity, entity) + if (!TryFindOwner(entity, out _, out Owner owner) || owner.RemovalPending || (resident ? owner.IsFullyResident : owner.IsFullySuspended)) { @@ -270,8 +285,7 @@ public sealed class EntitySpawnAdapter ArgumentNullException.ThrowIfNull(partOverrides); ArgumentNullException.ThrowIfNull(publishAppearance); - if (!_ownersByGuid.TryGetValue(entity.ServerGuid, out Owner? owner) - || !ReferenceEquals(owner.Entity, entity) + if (!TryFindOwner(entity, out _, out Owner owner) || owner.RemovalPending || owner.Transition != PresentationTransition.None) { @@ -340,19 +354,31 @@ public sealed class EntitySpawnAdapter /// and propagates the exception; a later call resumes its unfinished releases. /// public void OnRemove(uint serverGuid) - => _ = TryRemove(serverGuid, expectedEntity: null); - - private bool TryRemove(uint serverGuid, WorldEntity? expectedEntity) { - if (!_ownersByGuid.TryGetValue(serverGuid, out Owner? owner) - || (expectedEntity is not null && !ReferenceEquals(owner.Entity, expectedEntity))) + foreach ((RuntimeEntityKey key, Owner owner) in _ownersByKey) + { + if (owner.Entity.ServerGuid == serverGuid) + { + _ = TryRemove(key, owner.Entity); + return; + } + } + } + + private bool TryRemove( + RuntimeEntityKey key, + WorldEntity expectedEntity) + { + if (!_ownersByKey.TryGetValue(key, out Owner? owner) + || !ReferenceEquals(owner.Entity, expectedEntity)) { return false; } owner.RemovalPending = true; if (owner.Transition != PresentationTransition.None) - throw new EntityPresentationRemovalDeferredException(serverGuid); + throw new EntityPresentationRemovalDeferredException( + owner.Entity.ServerGuid); // A resident owner still holds both mesh and potentially-lazy texture // resources. A suspended owner released them at the visibility edge, @@ -363,10 +389,10 @@ public sealed class EntitySpawnAdapter if (owner.HasPresentationResources && !SuspendPresentation(owner)) return false; - if (_ownersByGuid.TryGetValue(serverGuid, out Owner? current) + if (_ownersByKey.TryGetValue(key, out Owner? current) && ReferenceEquals(current, owner)) { - _ownersByGuid.Remove(serverGuid); + _ownersByKey.Remove(key); return true; } @@ -384,7 +410,8 @@ public sealed class EntitySpawnAdapter public bool OnRemove(WorldEntity entity) { ArgumentNullException.ThrowIfNull(entity); - return TryRemove(entity.ServerGuid, entity); + return TryFindOwner(entity, out RuntimeEntityKey key, out _) + && TryRemove(key, entity); } /// @@ -393,9 +420,35 @@ public sealed class EntitySpawnAdapter /// been removed. /// public AnimatedEntityState? GetState(uint serverGuid) - => _ownersByGuid.TryGetValue(serverGuid, out Owner? owner) - ? owner.State - : null; + { + foreach (Owner owner in _ownersByKey.Values) + { + if (owner.Entity.ServerGuid == serverGuid) + return owner.State; + } + + return null; + } + + private bool TryFindOwner( + WorldEntity entity, + out RuntimeEntityKey key, + out Owner owner) + { + foreach ((RuntimeEntityKey candidateKey, Owner candidate) in _ownersByKey) + { + if (ReferenceEquals(candidate.Entity, entity)) + { + key = candidateKey; + owner = candidate; + return true; + } + } + + key = default; + owner = null!; + return false; + } private bool ResumePresentation(Owner owner) { diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 7d8dc419..47a72deb 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -150,15 +150,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery // This index mirrors only loaded live projections and changes at the same // transaction boundary as _projectionLocations. private readonly Dictionary> _loadedLiveByLandblock = new(); - // A server GUID normally has exactly one spatial projection. Keep that - // allocation-free common case directly in the primary map; the secondary - // list exists only for the rare overlap during GUID reuse/re-entrant - // lifetime transitions. - private readonly Dictionary _primaryProjectionByGuid = new(); - private readonly Dictionary> _additionalProjectionsByGuid = new(); + // Runtime-issued local IDs stay unique for the complete materialized + // lifetime, including retryable teardown. Spatial storage therefore never + // groups graphical owners by reusable server GUID. + private readonly Dictionary _liveProjectionByLocalId = new(); private readonly Dictionary _visibleLiveProjectionCounts = new(); private readonly Dictionary _visibilityBeforeMutation = new(); - private readonly Queue<(uint ServerGuid, bool Visible)> _visibilityTransitions = new(); + private readonly Queue<(uint LocalEntityId, bool Visible)> _visibilityTransitions = new(); + private readonly List _guidRemovalScratch = new(); private bool _dispatchingVisibilityTransitions; private int _mutationDepth; private long _visibilityCommitCount; @@ -180,18 +179,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loaded.ContainsKey(landblockId) && (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true); /// - /// True when at least one projection for the server object belongs to a - /// loaded spatial bucket. This deliberately ignores the world-generation + /// True when the exact Runtime-issued local projection belongs to a loaded + /// spatial bucket. This deliberately ignores the world-generation /// presentation gate: destination objects must acquire their render /// resources while portal/login reveal keeps normal world consumers closed. /// - public bool IsLiveEntityProjectionResident(uint serverGuid) => - serverGuid != 0 - && _visibleLiveProjectionCounts.ContainsKey(serverGuid); + public bool IsLiveEntityProjectionResident(uint localEntityId) => + localEntityId != 0 + && _visibleLiveProjectionCounts.ContainsKey(localEntityId); - public bool IsLiveEntityVisible(uint serverGuid) => + public bool IsLiveEntityVisible(uint localEntityId) => _availability.IsWorldAvailable - && IsLiveEntityProjectionResident(serverGuid); + && IsLiveEntityProjectionResident(localEntityId); /// /// Try to grab the loaded record for a landblock — useful for callers @@ -583,11 +582,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (--_mutationDepth != 0) return; - foreach ((uint guid, bool wasVisible) in _visibilityBeforeMutation) + foreach ((uint localEntityId, bool wasVisible) in _visibilityBeforeMutation) { - bool isVisible = _visibleLiveProjectionCounts.ContainsKey(guid); + bool isVisible = _visibleLiveProjectionCounts.ContainsKey(localEntityId); if (wasVisible != isVisible) - _visibilityTransitions.Enqueue((guid, isVisible)); + _visibilityTransitions.Enqueue((localEntityId, isVisible)); } _visibilityBeforeMutation.Clear(); _visibilityCommitCount++; @@ -604,26 +603,27 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery private void AdjustVisibleLiveProjection(WorldEntity entity, int delta) { - uint guid = entity.ServerGuid; - if (guid == 0 || delta == 0) + uint localEntityId = entity.Id; + if (entity.ServerGuid == 0 || localEntityId == 0 || delta == 0) return; - _visibleLiveProjectionCounts.TryGetValue(guid, out int priorCount); - if (!_visibilityBeforeMutation.ContainsKey(guid)) - _visibilityBeforeMutation.Add(guid, priorCount > 0); + _visibleLiveProjectionCounts.TryGetValue(localEntityId, out int priorCount); + if (!_visibilityBeforeMutation.ContainsKey(localEntityId)) + _visibilityBeforeMutation.Add(localEntityId, priorCount > 0); int nextCount = checked(priorCount + delta); if (nextCount < 0) throw new InvalidOperationException( - $"Live projection visibility count underflow for 0x{guid:X8}."); + $"Live projection visibility count underflow for local " + + $"0x{localEntityId:X8} / server 0x{entity.ServerGuid:X8}."); if (nextCount == 0) { - _visibleLiveProjectionCounts.Remove(guid); + _visibleLiveProjectionCounts.Remove(localEntityId); } else { - _visibleLiveProjectionCounts[guid] = nextCount; + _visibleLiveProjectionCounts[localEntityId] = nextCount; } } @@ -695,16 +695,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (!isNew) return; - uint guid = entity.ServerGuid; - if (_primaryProjectionByGuid.TryAdd(guid, entity)) - return; - - if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? projections)) + if (!_liveProjectionByLocalId.TryAdd(entity.Id, entity)) { - projections = new List(1); - _additionalProjectionsByGuid.Add(guid, projections); + throw new InvalidOperationException( + $"Runtime local projection id 0x{entity.Id:X8} is already spatially owned."); } - projections.Add(entity); } private void RemoveProjectionLocation(WorldEntity entity) @@ -715,51 +710,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (location.IsLoaded) RemoveLoadedLiveIndex(location.LandblockId, entity); - uint guid = entity.ServerGuid; - if (!_primaryProjectionByGuid.TryGetValue(guid, out WorldEntity? primary)) - throw new InvalidOperationException( - $"Live projection 0x{entity.Id:X8} had no server-guid index entry."); - - if (ReferenceEquals(primary, entity)) + if (!_liveProjectionByLocalId.Remove( + entity.Id, + out WorldEntity? indexed)) { - if (_additionalProjectionsByGuid.TryGetValue(guid, out List? projections) - && projections.Count > 0) - { - int lastIndex = projections.Count - 1; - _primaryProjectionByGuid[guid] = projections[lastIndex]; - projections.RemoveAt(lastIndex); - if (projections.Count == 0) - _additionalProjectionsByGuid.Remove(guid); - } - else - { - _primaryProjectionByGuid.Remove(guid); - } - return; - } - - if (!_additionalProjectionsByGuid.TryGetValue(guid, out List? additional)) throw new InvalidOperationException( - $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); - - int index = -1; - for (int i = 0; i < additional.Count; i++) + $"Live projection 0x{entity.Id:X8} had no exact local-id index entry."); + } + if (!ReferenceEquals(indexed, entity)) { - if (!ReferenceEquals(additional[i], entity)) - continue; - index = i; - break; - } - if (index < 0) throw new InvalidOperationException( - $"Live projection 0x{entity.Id:X8} was absent from its server-guid index."); - - int lastAdditionalIndex = additional.Count - 1; - if (index != lastAdditionalIndex) - additional[index] = additional[lastAdditionalIndex]; - additional.RemoveAt(lastAdditionalIndex); - if (additional.Count == 0) - _additionalProjectionsByGuid.Remove(guid); + $"Runtime local projection id 0x{entity.Id:X8} resolved a different owner."); + } } private void AddLoadedLiveIndex(uint landblockId, WorldEntity entity) @@ -1397,10 +1359,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery MutationBatch mutation = BeginMutationBatch(); try { - foreach ((uint guid, int count) in _visibleLiveProjectionCounts) + foreach ((uint localEntityId, int count) in _visibleLiveProjectionCounts) { - if (count > 0 && !_visibilityBeforeMutation.ContainsKey(guid)) - _visibilityBeforeMutation.Add(guid, true); + if (count > 0 + && !_visibilityBeforeMutation.ContainsKey(localEntityId)) + { + _visibilityBeforeMutation.Add(localEntityId, true); + } } _visibleLiveProjectionCounts.Clear(); @@ -1410,8 +1375,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _flatEntityIndices.Clear(); _projectionLocations.Clear(); _loadedLiveByLandblock.Clear(); - _primaryProjectionByGuid.Clear(); - _additionalProjectionsByGuid.Clear(); + _liveProjectionByLocalId.Clear(); _loaded.Clear(); _renderTraversalLandblockSlots.Clear(); @@ -1525,12 +1489,18 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery using MutationBatch mutation = BeginMutationBatch(); - // Canonical live projections are indexed by server GUID and entity - // identity, so delete/withdraw never scans every resident landblock. - while (_primaryProjectionByGuid.TryGetValue(serverGuid, out WorldEntity? projection)) + // GUID-only removal is reserved for session cleanup paths that do not + // have an exact projection owner. Ordinary lifecycle operations use + // the exact-entity overload below. + _guidRemovalScratch.Clear(); + foreach (WorldEntity projection in _projectionLocations.Keys) { - RemoveEntityFromAllBuckets(projection); + if (projection.ServerGuid == serverGuid) + _guidRemovalScratch.Add(projection); } + for (int i = 0; i < _guidRemovalScratch.Count; i++) + RemoveEntityFromAllBuckets(_guidRemovalScratch[i]); + _guidRemovalScratch.Clear(); // A persistent projection may have been rescued by RemoveLandblock // and not yet drained by the next GameWindow frame. Rebucketing or @@ -1582,8 +1552,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _visibilityBeforeMutation.Clear(); _projectionLocations.Clear(); _loadedLiveByLandblock.Clear(); - _primaryProjectionByGuid.Clear(); - _additionalProjectionsByGuid.Clear(); + _liveProjectionByLocalId.Clear(); } /// @@ -1905,7 +1874,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery try { ((Action)subscribers[i])( - transition.ServerGuid, + transition.LocalEntityId, transition.Visible); } catch (Exception error) diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 92b5f257..cdb83c6c 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -229,7 +229,7 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme // SnapToCell owns the retail Position frame and may normalize an // outdoor land-cell index from the cell-local origin. Publish that // canonical result, not the pre-snap resolver hint, to rendering. - if (_liveEntities.MaterializedWorldEntities.TryGetValue( + if (_liveEntities.TryGetWorldEntity( playerGuid, out WorldEntity? entity)) { diff --git a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs index d582e070..9de0876f 100644 --- a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs +++ b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs @@ -62,18 +62,22 @@ internal sealed class WorldGenerationQuiescence private readonly WorldGenerationAvailabilityState _availability; private readonly SelectionState _selection; private readonly GpuWorldState _world; + private readonly Func _resolveLocalEntityId; private readonly IWorldAudioQuiescence? _audio; public WorldGenerationQuiescence( WorldGenerationAvailabilityState availability, SelectionState selection, GpuWorldState world, + Func resolveLocalEntityId, IWorldAudioQuiescence? audio) { _availability = availability ?? throw new ArgumentNullException(nameof(availability)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _world = world ?? throw new ArgumentNullException(nameof(world)); + _resolveLocalEntityId = resolveLocalEntityId + ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); _audio = audio; } @@ -85,7 +89,8 @@ internal sealed class WorldGenerationQuiescence uint? selected = firstEdge ? _selection.SelectedObjectId : null; bool clearWorldSelection = selected is uint selectedGuid - && _world.IsLiveEntityVisible(selectedGuid); + && _resolveLocalEntityId(selectedGuid) is uint localEntityId + && _world.IsLiveEntityVisible(localEntityId); _availability.Begin(generation); if (!firstEdge) diff --git a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs index 96cafad2..004a357b 100644 --- a/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs +++ b/src/AcDream.App/UI/Layout/RadarSnapshotProvider.cs @@ -20,8 +20,7 @@ public sealed class RadarSnapshotProvider private static readonly Vector2 ProductionCenter = new(60f, 60f); private readonly ClientObjectTable _objects; - private readonly Func> _worldEntities; - private readonly Func> _playerEntities; + private readonly ILiveEntityRadarSource _liveEntities; private readonly Func> _spawns; private readonly Func _playerGuid; private readonly Func _playerYawRadians; @@ -35,7 +34,7 @@ public sealed class RadarSnapshotProvider public RadarSnapshotProvider( ClientObjectTable objects, - Func> worldEntities, + ILiveEntityRadarSource liveEntities, Func> spawns, Func playerGuid, Func playerYawRadians, @@ -44,12 +43,10 @@ public sealed class RadarSnapshotProvider Func coordinatesOnRadar, Func uiLocked, Func? relationshipFor = null, - Func>? playerEntities = null, Func? spatialQuery = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); - _worldEntities = worldEntities ?? throw new ArgumentNullException(nameof(worldEntities)); - _playerEntities = playerEntities ?? worldEntities; + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _spawns = spawns ?? throw new ArgumentNullException(nameof(spawns)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerYawRadians = playerYawRadians ?? throw new ArgumentNullException(nameof(playerYawRadians)); @@ -67,13 +64,14 @@ public sealed class RadarSnapshotProvider // startup. Resolve these canonical projections per snapshot so the // provider observes that runtime once it is installed, rather than // retaining the bootstrap empty-map sentinels forever. - IReadOnlyDictionary worldEntities = _worldEntities(); - IReadOnlyDictionary playerEntities = _playerEntities(); IReadOnlyDictionary spawns = _spawns(); bool uiLocked = _uiLocked(); uint playerGuid = _playerGuid(); - if (playerGuid == 0u || !playerEntities.TryGetValue(playerGuid, out var playerEntity)) + if (playerGuid == 0u + || !_liveEntities.TryGetMaterialized( + playerGuid, + out WorldEntity playerEntity)) return UiRadarSnapshot.Empty with { UiLocked = uiLocked }; float heading = MoveToMath.HeadingFromYaw(_playerYawRadians()); @@ -104,8 +102,7 @@ public sealed class RadarSnapshotProvider } else { - foreach (var pair in worldEntities) - _candidateScratch.Add(pair); + _liveEntities.CopyVisibleTo(_candidateScratch); } var blips = new List(Math.Min(_candidateScratch.Count, 64)); @@ -116,7 +113,7 @@ public sealed class RadarSnapshotProvider continue; var entity = pair.Value; - if (!worldEntities.TryGetValue(guid, out WorldEntity? visibleEntity) + if (!_liveEntities.TryGetVisible(guid, out WorldEntity? visibleEntity) || !ReferenceEquals(entity, visibleEntity)) { continue; diff --git a/src/AcDream.App/Update/LiveObjectFrameController.cs b/src/AcDream.App/Update/LiveObjectFrameController.cs index fd8dd66a..99c698f5 100644 --- a/src/AcDream.App/Update/LiveObjectFrameController.cs +++ b/src/AcDream.App/Update/LiveObjectFrameController.cs @@ -10,6 +10,7 @@ using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.Vfx; using AcDream.Core.World; +using AcDream.Runtime.Entities; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Update; @@ -196,12 +197,12 @@ internal sealed class LiveObjectFrameController : ILiveObjectFramePhase _selectionInteractions?.DrainOutbound(); Vector3? playerPosition = - _liveEntities.MaterializedWorldEntities.TryGetValue( + _liveEntities.TryGetWorldEntity( _localPlayer.ServerGuid, out WorldEntity? player) ? player.Position : null; - IReadOnlyDictionary schedules = + IReadOnlyDictionary schedules = _animations.Tick( deltaSeconds, playerPosition, diff --git a/src/AcDream.App/World/ILiveEntityRadarSource.cs b/src/AcDream.App/World/ILiveEntityRadarSource.cs new file mode 100644 index 00000000..bb43a239 --- /dev/null +++ b/src/AcDream.App/World/ILiveEntityRadarSource.cs @@ -0,0 +1,15 @@ +using AcDream.Core.World; + +namespace AcDream.App.World; + +/// +/// Allocation-free live-projection query used by the radar. Implementations +/// expose current presentation state without publishing a second GUID identity +/// map. +/// +public interface ILiveEntityRadarSource +{ + bool TryGetMaterialized(uint serverGuid, out WorldEntity entity); + bool TryGetVisible(uint serverGuid, out WorldEntity entity); + void CopyVisibleTo(List> destination); +} diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index 4098501b..3dfebffa 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -78,9 +78,9 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink public bool Prune(LiveEntityPruneCandidate candidate) { if (!_runtime.TryGetRecord( - candidate.ServerGuid, + candidate.Key, out LiveEntityRecord record) - || record.Generation != candidate.Generation) + || record.ServerGuid != candidate.ServerGuid) { return false; } diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 08ac8a98..2ea70f61 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -24,7 +24,7 @@ internal enum LiveProjectionPurpose internal interface ILiveEntityProjectionMaterializer { bool TryMaterialize( - LiveEntityRecord expectedRecord, + RuntimeEntityRecord expectedCanonical, WorldSession.EntitySpawn canonicalSpawn, LiveProjectionPurpose purpose, ulong expectedCreateIntegrationVersion, @@ -181,6 +181,15 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly LiveEntityDeletionController _deletion; private readonly DormantLiveEntityStore _dormant; private readonly Action? _diagnostic; + private readonly Dictionary + _projectionOperations = + new(ReferenceEqualityComparer.Instance); + + private sealed class CanonicalProjectionOperation + { + public ulong CreateIntegrationVersion { get; set; } + public bool RetryRequested { get; set; } + } public LiveEntityHydrationController( LiveEntityRuntime runtime, @@ -264,17 +273,17 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded // dormant snapshot until an active record actually accepts // ownership; otherwise a transient teardown failure would discard // the only ACE revisit source. - if (registration.Record is not { } record) + if (registration.Canonical is not { } canonical) return; _dormant.RemoveThroughAcceptedCreate(spawn); - ulong createIntegrationVersion = record.CreateIntegrationVersion; + ulong createIntegrationVersion = canonical.CreateIntegrationVersion; try { _timestamps.Publish(spawn.Guid, result.Timestamps); if (_runtime.IsCurrentCreateIntegration( - record, + canonical, createIntegrationVersion) && ObjectTableWiring.ApplyEntitySpawn( _objects, @@ -283,10 +292,10 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration || dormantDisposition is DormantCreateDisposition.NewGeneration, accepting: () => _runtime.IsCurrentCreateIntegration( - record, + canonical, createIntegrationVersion)) && _runtime.IsCurrentCreateIntegration( - record, + canonical, createIntegrationVersion)) { if (result.Disposition is @@ -300,7 +309,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded // that snapshot rather than replaying a delta tail // against owners that did not exist. ProjectExact( - record, + canonical, result.Snapshot, LiveProjectionPurpose.LogicalRegistration, expectedCreateIntegrationVersion: @@ -312,15 +321,23 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _networkUpdates.ApplySameGeneration(refresh); if (_runtime.IsCurrentCreateIntegration( - record, - createIntegrationVersion) - && (record.CreateProjectionSynchronizationPending - || !record.InitialHydrationCompleted)) + canonical, + createIntegrationVersion)) { + _runtime.TryGetProjection( + canonical, + out LiveEntityRecord? projection); + if (projection is not null + && !projection.CreateProjectionSynchronizationPending + && projection.InitialHydrationCompleted) + { + goto AppearanceSynchronization; + } ProjectExact( - record, - record.Snapshot, - record.CreateProjectionSynchronizationPending + canonical, + canonical.Snapshot, + projection?.CreateProjectionSynchronizationPending + is true ? LiveProjectionPurpose.CreateSupersessionRecovery : LiveProjectionPurpose.SpatialRecovery, expectedCreateIntegrationVersion: @@ -331,19 +348,22 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded else { ProjectExact( - record, + canonical, result.Snapshot, LiveProjectionPurpose.LogicalRegistration, expectedCreateIntegrationVersion: createIntegrationVersion); } - if (_runtime.IsCurrentRecord(record) - && record.AppearanceProjectionSynchronizationPending) +AppearanceSynchronization: + if (_runtime.TryGetProjection( + canonical, + out LiveEntityRecord? currentProjection) + && currentProjection.AppearanceProjectionSynchronizationPending) { SynchronizeAppearance( - record, - record.ObjDescAuthorityVersion); + currentProjection, + currentProjection.ObjDescAuthorityVersion); } } } @@ -375,12 +395,25 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded { lock (_datLock) { - if (!_runtime.TryApplyObjDesc(update, out _) - || !_runtime.TryGetRecord(update.Guid, out LiveEntityRecord record)) + if (!_runtime.TryApplyObjDesc(update, out _)) { return false; } + if (!_runtime.TryGetRecord( + update.Guid, + out LiveEntityRecord record)) + { + return _runtime.TryGetCanonical( + update.Guid, + out RuntimeEntityRecord canonical) + && ProjectExact( + canonical, + canonical.Snapshot, + LiveProjectionPurpose.SpatialRecovery, + canonical.CreateIntegrationVersion); + } + return SynchronizeAppearance( record, record.ObjDescAuthorityVersion); @@ -466,42 +499,64 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded if (_runtime.Count == 0) return; - uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; - LiveEntityRecord[] records = _runtime.Records - .Where(record => (record.ServerGuid != _identity.ServerGuid - || !record.InitialHydrationCompleted - || record.CreateProjectionSynchronizationPending - || record.AppearanceProjectionSynchronizationPending) - && record.ProjectionCellId != 0 - && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical - && record.Snapshot.SetupTableId is not null) - .ToArray(); - if (records.Length == 0) + uint canonicalLandblock = + (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; + var candidates = new List(); + foreach (RuntimeEntityRecord candidate in _runtime.CanonicalRecords) + { + _runtime.TryGetProjection( + candidate, + out LiveEntityRecord? projection); + if (candidate.ServerGuid == _identity.ServerGuid + && projection is not null + && projection.InitialHydrationCompleted + && !projection.CreateProjectionSynchronizationPending + && !projection.AppearanceProjectionSynchronizationPending) + { + continue; + } + + uint projectionCellId = projection?.ProjectionCellId + ?? candidate.Snapshot.Position?.LandblockId + ?? candidate.FullCellId; + if (projectionCellId != 0 + && ((projectionCellId & 0xFFFF0000u) | 0xFFFFu) + == canonicalLandblock + && candidate.Snapshot.SetupTableId is not null) + { + candidates.Add(candidate); + } + } + if (candidates.Count == 0) return; int projected = 0; lock (_datLock) { - foreach (LiveEntityRecord record in records) + foreach (RuntimeEntityRecord candidate in candidates) { - if (!_runtime.IsCurrentRecord(record)) + if (!_runtime.IsCurrentCanonical(candidate)) continue; - ulong projectedObjDescVersion = record.ObjDescAuthorityVersion; + _runtime.TryGetProjection( + candidate, + out LiveEntityRecord? record); + ulong projectedObjDescVersion = + candidate.ObjDescAuthorityVersion; bool projectedCanonicalAppearance = false; - if (record.CreateProjectionSynchronizationPending) + if (record?.CreateProjectionSynchronizationPending is true) { if (ProjectExact( - record, - record.Snapshot, + candidate, + candidate.Snapshot, LiveProjectionPurpose.CreateSupersessionRecovery)) { projected++; projectedCanonicalAppearance = true; } } - else if (record.WorldEntity is not null + else if (record?.WorldEntity is not null && record.InitialHydrationCompleted) { if (_runtime.RebucketLiveEntity( @@ -512,15 +567,17 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded } } else if (ProjectExact( - record, - record.Snapshot, + candidate, + candidate.Snapshot, LiveProjectionPurpose.SpatialRecovery)) { projected++; projectedCanonicalAppearance = true; } - if (projectedCanonicalAppearance + _runtime.TryGetProjection(candidate, out record); + if (record is not null + && projectedCanonicalAppearance && record.AppearanceProjectionSynchronizationPending) { CompleteAppearanceProjectionSynchronization( @@ -528,7 +585,8 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded projectedObjDescVersion); } - if (_runtime.IsCurrentRecord(record) + if (record is not null + && _runtime.IsCurrentRecord(record) && record.AppearanceProjectionSynchronizationPending && SynchronizeAppearance( record, @@ -684,7 +742,7 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded else { published = _materializer.TryMaterialize( - expectedRecord, + expectedRecord.Canonical, expectedRecord.Snapshot, LiveProjectionPurpose.AppearanceMutation, expectedRecord.CreateIntegrationVersion, @@ -715,78 +773,138 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded LiveEntityRecord expectedRecord, WorldSession.EntitySpawn acceptedSpawn, LiveProjectionPurpose purpose, + ulong? expectedCreateIntegrationVersion = null) => + ProjectExact( + expectedRecord.Canonical, + acceptedSpawn, + purpose, + expectedCreateIntegrationVersion); + + private bool ProjectExact( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn acceptedSpawn, + LiveProjectionPurpose purpose, ulong? expectedCreateIntegrationVersion = null) { - while (true) + if (_projectionOperations.TryGetValue( + expectedCanonical, + out CanonicalProjectionOperation? active)) { - ulong createIntegrationVersion = expectedCreateIntegrationVersion - ?? expectedRecord.CreateIntegrationVersion; - if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery - && !_runtime.TryMarkCreateProjectionSynchronizationPending( - expectedRecord, - createIntegrationVersion)) + if (expectedCanonical.CreateIntegrationVersion + != active.CreateIntegrationVersion) { - return false; - } - if (!_runtime.TryBeginProjectionHydration( - expectedRecord, - createIntegrationVersion)) - { - return false; + active.RetryRequested = true; } + return false; + } - bool retry; - bool result; - try + var operation = new CanonicalProjectionOperation(); + _projectionOperations.Add(expectedCanonical, operation); + try + { + while (true) { - result = ProjectExactOnce( - expectedRecord, + ulong createIntegrationVersion = expectedCreateIntegrationVersion + ?? expectedCanonical.CreateIntegrationVersion; + if (!_runtime.IsCurrentCreateIntegration( + expectedCanonical, + createIntegrationVersion)) + { + return false; + } + if (purpose is LiveProjectionPurpose.CreateSupersessionRecovery + && _runtime.TryGetProjection( + expectedCanonical, + out LiveEntityRecord? synchronizedProjection) + && !_runtime.TryMarkCreateProjectionSynchronizationPending( + synchronizedProjection, + createIntegrationVersion)) + { + return false; + } + + operation.RetryRequested = false; + operation.CreateIntegrationVersion = + createIntegrationVersion; + bool result = ProjectExactOnce( + expectedCanonical, acceptedSpawn, purpose, createIntegrationVersion); + + bool retry = operation.RetryRequested + || (_runtime.IsCurrentCanonical(expectedCanonical) + && expectedCanonical.CreateIntegrationVersion + != createIntegrationVersion); + if (!retry || !_runtime.IsCurrentCanonical(expectedCanonical)) + return result; + + // A fresher same-incarnation CreateObject or a synchronous + // loaded-landblock callback re-entered this exact canonical + // construction. Resume immediately from Runtime's newest + // state; no projection work is deferred to another frame. + if (_runtime.TryGetProjection( + expectedCanonical, + out LiveEntityRecord? retryProjection)) + { + retryProjection.CreateProjectionSynchronizationPending = true; + } + acceptedSpawn = expectedCanonical.Snapshot; + purpose = LiveProjectionPurpose.CreateSupersessionRecovery; + expectedCreateIntegrationVersion = + expectedCanonical.CreateIntegrationVersion; } - finally + } + catch + { + if (_runtime.IsCurrentCanonical(expectedCanonical) + && _runtime.TryGetProjection( + expectedCanonical, + out LiveEntityRecord? interruptedProjection)) { - retry = _runtime.EndProjectionHydration(expectedRecord); + interruptedProjection.CreateProjectionSynchronizationPending = true; + } + throw; + } + finally + { + if (!_projectionOperations.Remove(expectedCanonical, out var removed) + || !ReferenceEquals(removed, operation)) + { + throw new InvalidOperationException( + "Canonical projection construction ownership was corrupted."); } - - if (!retry || !_runtime.IsCurrentRecord(expectedRecord)) - return result; - - // A fresher same-incarnation CreateObject arrived while this - // transaction owned the hydration edge. Resume in-place from the - // newest canonical snapshot; logical resources remain registered. - acceptedSpawn = expectedRecord.Snapshot; - purpose = LiveProjectionPurpose.CreateSupersessionRecovery; - expectedCreateIntegrationVersion = - expectedRecord.CreateIntegrationVersion; } } private bool ProjectExactOnce( - LiveEntityRecord expectedRecord, + RuntimeEntityRecord expectedCanonical, WorldSession.EntitySpawn acceptedSpawn, LiveProjectionPurpose purpose, ulong createIntegrationVersion) { _relationships.OnSpawn(acceptedSpawn); if (!_runtime.IsCurrentCreateIntegration( - expectedRecord, + expectedCanonical, createIntegrationVersion)) return false; // A queued Parent event can synchronously mutate the canonical // Position/Parent snapshot. Never continue from the stale argument. - WorldSession.EntitySpawn canonicalSpawn = expectedRecord.Snapshot; + WorldSession.EntitySpawn canonicalSpawn = expectedCanonical.Snapshot; InitializeOriginAndRecoverLoaded(canonicalSpawn); if (!_runtime.IsCurrentCreateIntegration( - expectedRecord, + expectedCanonical, createIntegrationVersion) || !_origin.IsKnown) { return false; } + _runtime.TryGetProjection( + expectedCanonical, + out LiveEntityRecord? expectedRecord); + // Retail's same-instance CreateObject branch completes a POSITION_TS // with no world Position through DoParentEvent or DoPickupEvent, not // through CPhysicsObj::enter_world. The relationship/update owners @@ -797,6 +915,9 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded { if (canonicalSpawn.ParentGuid is not null and not 0) { + if (expectedRecord is null) + return false; + if (expectedRecord.InitialHydrationCompleted && !expectedRecord.CreateProjectionSynchronizationPending) { @@ -822,19 +943,20 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded createIntegrationVersion); } - if (expectedRecord.FullCellId != 0 - || expectedRecord.IsSpatiallyProjected) + if (expectedCanonical.FullCellId != 0 + || expectedRecord?.IsSpatiallyProjected is true) { return false; } - if (expectedRecord.WorldEntity is null) + if (expectedRecord?.WorldEntity is null) { // A pickup can supersede initial hydration before a render // projection exists. The logical snapshot/table state is // already canonical; ready remains false so a later world // Position still constructs and publishes the first owner. - return !expectedRecord.CreateProjectionSynchronizationPending + return expectedRecord is null + || !expectedRecord.CreateProjectionSynchronizationPending || _runtime.TryCompleteCreateProjectionSynchronization( expectedRecord, createIntegrationVersion); @@ -854,19 +976,26 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded } bool materialized = _materializer.TryMaterialize( - expectedRecord, - expectedRecord.Snapshot, + expectedCanonical, + expectedCanonical.Snapshot, purpose, createIntegrationVersion); if (!materialized || !_runtime.IsCurrentCreateIntegration( - expectedRecord, + expectedCanonical, createIntegrationVersion) || purpose is LiveProjectionPurpose.AppearanceMutation) { return materialized; } + if (!_runtime.TryGetProjection( + expectedCanonical, + out expectedRecord)) + { + return false; + } + if (!PublishReady( expectedRecord, createIntegrationVersion)) diff --git a/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs b/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs deleted file mode 100644 index 6e87b47d..00000000 --- a/src/AcDream.App/World/LiveEntityIncarnationCleanup.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace AcDream.App.World; - -/// -/// Protects GUID-keyed runtime state while an older incarnation is draining. -/// A teardown tombstone may outlive the active record and a newer generation -/// may already own the same server GUID; only state captured by reference may -/// be removed unconditionally in that case. -/// -internal sealed class LiveEntityIncarnationCleanup -{ - private readonly LiveEntityRecord _owner; - private readonly Func _resolveCurrent; - - public LiveEntityIncarnationCleanup( - LiveEntityRecord owner, - Func resolveCurrent) - { - _owner = owner ?? throw new ArgumentNullException(nameof(owner)); - _resolveCurrent = resolveCurrent - ?? throw new ArgumentNullException(nameof(resolveCurrent)); - } - - /// - /// Runs a GUID-scoped mutation only while no replacement incarnation owns - /// that GUID. The original record may still be current (projection-only - /// withdrawal) or may already have moved into the teardown ledger. - /// - public void RunIfNoReplacement(Action mutation) - { - ArgumentNullException.ThrowIfNull(mutation); - LiveEntityRecord? current = _resolveCurrent(_owner.ServerGuid); - if (current is null || ReferenceEquals(current, _owner)) - mutation(); - } - - /// - /// Removes a reference-owned dictionary entry only when the entry still - /// points at this incarnation's captured owner. This remains safe even if - /// a replacement has overwritten the same GUID between plan creation and - /// execution. - /// - public void RemoveCaptured( - IDictionary owners, - T captured) - where T : class - { - ArgumentNullException.ThrowIfNull(owners); - ArgumentNullException.ThrowIfNull(captured); - if (owners.TryGetValue(_owner.ServerGuid, out T? current) - && ReferenceEquals(current, captured)) - { - owners.Remove(_owner.ServerGuid); - } - } -} diff --git a/src/AcDream.App/World/LiveEntityLivenessController.cs b/src/AcDream.App/World/LiveEntityLivenessController.cs index 79bf4240..5ccad470 100644 --- a/src/AcDream.App/World/LiveEntityLivenessController.cs +++ b/src/AcDream.App/World/LiveEntityLivenessController.cs @@ -1,17 +1,25 @@ using AcDream.Core.Net.Messages; using AcDream.App.Input; +using AcDream.Runtime.Entities; namespace AcDream.App.World; internal readonly record struct LiveEntityLivenessSample( + RuntimeEntityKey Key, uint ServerGuid, - ushort Generation, bool IsConservativelyVisible, bool HasNonWorldRetention); internal readonly record struct LiveEntityPruneCandidate( + RuntimeEntityKey Key, uint ServerGuid, - ushort Generation); + ushort Generation) +{ + public LiveEntityPruneCandidate(RuntimeEntityKey key, uint serverGuid) + : this(key, serverGuid, key.Incarnation) + { + } +} /// /// Deadline state for ACE's retained KnownObjects behavior. Retail @@ -23,7 +31,10 @@ internal sealed class LiveEntityLivenessTracker { internal const double DestructionTimeoutSeconds = 25.0; - private readonly Dictionary _deadlines = new(); + private readonly Dictionary _deadlines = []; + private readonly HashSet _present = []; + private readonly List _stale = []; + private readonly List _due = []; internal int DeadlineCount => _deadlines.Count; @@ -31,46 +42,44 @@ internal sealed class LiveEntityLivenessTracker double now, IReadOnlyList samples) { - var present = new HashSet(samples.Count); - var due = new List(); + _present.Clear(); + _due.Clear(); for (int i = 0; i < samples.Count; i++) { LiveEntityLivenessSample sample = samples[i]; - present.Add(sample.ServerGuid); + _present.Add(sample.Key); if (sample.IsConservativelyVisible || sample.HasNonWorldRetention) { - _deadlines.Remove(sample.ServerGuid); + _deadlines.Remove(sample.Key); continue; } - if (!_deadlines.TryGetValue(sample.ServerGuid, out Deadline deadline) - || deadline.Generation != sample.Generation) + if (!_deadlines.TryGetValue(sample.Key, out double expiresAt)) { - _deadlines[sample.ServerGuid] = new Deadline( - sample.Generation, - now + DestructionTimeoutSeconds); + _deadlines[sample.Key] = now + DestructionTimeoutSeconds; continue; } - if (deadline.ExpiresAt > now) + if (expiresAt > now) continue; - due.Add(new LiveEntityPruneCandidate(sample.ServerGuid, sample.Generation)); - _deadlines.Remove(sample.ServerGuid); + _due.Add(new LiveEntityPruneCandidate(sample.Key, sample.ServerGuid)); + _deadlines.Remove(sample.Key); } - uint[] stale = _deadlines.Keys - .Where(guid => !present.Contains(guid)) - .ToArray(); - for (int i = 0; i < stale.Length; i++) - _deadlines.Remove(stale[i]); + _stale.Clear(); + foreach (RuntimeEntityKey key in _deadlines.Keys) + { + if (!_present.Contains(key)) + _stale.Add(key); + } + for (int i = 0; i < _stale.Count; i++) + _deadlines.Remove(_stale[i]); - return due; + return _due; } internal void Clear() => _deadlines.Clear(); - - private readonly record struct Deadline(ushort Generation, double ExpiresAt); } /// @@ -134,8 +143,11 @@ internal sealed class LiveEntityLivenessController || NonZero(record.Snapshot.WielderId) || NonZero(record.Snapshot.ParentGuid); _samples.Add(new LiveEntityLivenessSample( + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Materialized liveness owner 0x{record.ServerGuid:X8}/" + + $"{record.Generation} has no exact projection key."), record.ServerGuid, - record.Generation, record.IsSpatiallyVisible || IsWithinConservativeVisibility(playerPosition, position), retained)); @@ -145,8 +157,8 @@ internal sealed class LiveEntityLivenessController for (int i = 0; i < due.Count; i++) { LiveEntityPruneCandidate candidate = due[i]; - if (_runtime.TryGetRecord(candidate.ServerGuid, out LiveEntityRecord current) - && current.Generation == candidate.Generation) + if (_runtime.TryGetRecord(candidate.Key, out LiveEntityRecord current) + && current.ServerGuid == candidate.ServerGuid) { _prune.Prune(candidate); } diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index 417ebca5..79653261 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -1,5 +1,6 @@ using AcDream.App.Physics; using AcDream.Core.Physics; +using AcDream.Runtime.Entities; namespace AcDream.App.World; @@ -28,9 +29,9 @@ public sealed class LiveEntityPresentationController : IDisposable private readonly Func<(int X, int Y)> _liveCenter; private readonly Action? _onShadowRestored; private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld; - private readonly Dictionary _readyGenerationByGuid = new(); - private readonly Dictionary _suspendedShadowGenerationByGuid = new(); - private readonly Dictionary _activePlacementGenerationByGuid = new(); + private readonly HashSet _readyOwners = []; + private readonly HashSet _suspendedShadowOwners = []; + private readonly HashSet _activePlacementOwners = []; private readonly HashSet _drainingRecords = new(); private bool _disposed; @@ -70,7 +71,8 @@ public sealed class LiveEntityPresentationController : IDisposable return false; } - _readyGenerationByGuid[serverGuid] = record.Generation; + RuntimeEntityKey key = RequireProjectionKey(record); + _readyOwners.Add(key); if (!ApplyPendingTransitions(record) || record.WorldEntity is not { } entity || !IsCurrent(record, entity)) @@ -91,8 +93,8 @@ public sealed class LiveEntityPresentationController : IDisposable public bool OnStateAccepted(uint serverGuid) { if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - || !_readyGenerationByGuid.TryGetValue(serverGuid, out ushort generation) - || generation != record.Generation) + || !TryGetProjectionKey(record, out RuntimeEntityKey key) + || !_readyOwners.Contains(key)) { return false; } @@ -126,21 +128,13 @@ public sealed class LiveEntityPresentationController : IDisposable return false; } - if (_activePlacementGenerationByGuid.TryGetValue( - serverGuid, - out ushort activeGeneration) - && activeGeneration == generation) - { - _activePlacementGenerationByGuid.Remove(serverGuid); - } + RuntimeEntityKey key = RequireProjectionKey(record); + _activePlacementOwners.Remove(key); if (deferShadowRestore) - _suspendedShadowGenerationByGuid[serverGuid] = generation; - else if (_suspendedShadowGenerationByGuid.TryGetValue( - serverGuid, - out ushort deferredGeneration) - && deferredGeneration == generation) - _suspendedShadowGenerationByGuid.Remove(serverGuid); + _suspendedShadowOwners.Add(key); + else + _suspendedShadowOwners.Remove(key); return true; } @@ -158,57 +152,41 @@ public sealed class LiveEntityPresentationController : IDisposable return false; } - _activePlacementGenerationByGuid[serverGuid] = generation; - if (_suspendedShadowGenerationByGuid.TryGetValue( - serverGuid, - out ushort deferredGeneration) - && deferredGeneration == generation) - { - _suspendedShadowGenerationByGuid.Remove(serverGuid); - } + RuntimeEntityKey key = RequireProjectionKey(record); + _activePlacementOwners.Add(key); + _suspendedShadowOwners.Remove(key); return true; } internal bool HasDeferredShadowRestore(uint serverGuid) => - _suspendedShadowGenerationByGuid.ContainsKey(serverGuid); + TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key) + && _suspendedShadowOwners.Contains(key); internal bool HasActivePlacement(uint serverGuid) => - _activePlacementGenerationByGuid.ContainsKey(serverGuid); + TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key) + && _activePlacementOwners.Contains(key); - internal int ReadyOwnerCount => _readyGenerationByGuid.Count; - internal int DeferredShadowRestoreCount => _suspendedShadowGenerationByGuid.Count; - internal int ActivePlacementCount => _activePlacementGenerationByGuid.Count; + internal int ReadyOwnerCount => _readyOwners.Count; + internal int DeferredShadowRestoreCount => _suspendedShadowOwners.Count; + internal int ActivePlacementCount => _activePlacementOwners.Count; public void Forget(LiveEntityRecord record) { ArgumentNullException.ThrowIfNull(record); - if (_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort generation) - && generation == record.Generation) + if (TryGetProjectionKey(record, out RuntimeEntityKey key)) { - _readyGenerationByGuid.Remove(record.ServerGuid); - } - if (_suspendedShadowGenerationByGuid.TryGetValue( - record.ServerGuid, - out ushort suspendedGeneration) - && suspendedGeneration == record.Generation) - { - _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); - } - if (_activePlacementGenerationByGuid.TryGetValue( - record.ServerGuid, - out ushort activeGeneration) - && activeGeneration == record.Generation) - { - _activePlacementGenerationByGuid.Remove(record.ServerGuid); + _readyOwners.Remove(key); + _suspendedShadowOwners.Remove(key); + _activePlacementOwners.Remove(key); } _drainingRecords.Remove(record); } public void Clear() { - _readyGenerationByGuid.Clear(); - _suspendedShadowGenerationByGuid.Clear(); - _activePlacementGenerationByGuid.Clear(); + _readyOwners.Clear(); + _suspendedShadowOwners.Clear(); + _activePlacementOwners.Clear(); _drainingRecords.Clear(); } @@ -256,7 +234,7 @@ public sealed class LiveEntityPresentationController : IDisposable return false; _shadows.Suspend(entity.Id); if (!IsPlacementActive(record)) - _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; + _suspendedShadowOwners.Add(RequireProjectionKey(record)); // Retail CPhysicsObj::set_hidden @ 0x00514C60 calls // CPartArray::HandleEnterWorld after hiding the object // from its cell. Despite the name, this is the motion @@ -288,7 +266,7 @@ public sealed class LiveEntityPresentationController : IDisposable if (!IsCurrent(record, entity)) return false; if (restored) - _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); + _suspendedShadowOwners.Remove(RequireProjectionKey(record)); break; } } @@ -345,12 +323,9 @@ public sealed class LiveEntityPresentationController : IDisposable if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 || IsPlacementActive(record) - || !_readyGenerationByGuid.TryGetValue(record.ServerGuid, out ushort readyGeneration) - || readyGeneration != record.Generation - || !_suspendedShadowGenerationByGuid.TryGetValue( - record.ServerGuid, - out ushort suspendedGeneration) - || suspendedGeneration != record.Generation) + || !TryGetProjectionKey(record, out RuntimeEntityKey key) + || !_readyOwners.Contains(key) + || !_suspendedShadowOwners.Contains(key)) { return; } @@ -359,7 +334,7 @@ public sealed class LiveEntityPresentationController : IDisposable if (!IsCurrent(record, entity)) return; if (restored) - _suspendedShadowGenerationByGuid.Remove(record.ServerGuid); + _suspendedShadowOwners.Remove(key); } private void SuspendOrdinaryShadowOutsideProjection( @@ -369,16 +344,14 @@ public sealed class LiveEntityPresentationController : IDisposable if (record.IsSpatiallyVisible || record.ProjectileRuntime is not null || IsPlacementActive(record) - || !_readyGenerationByGuid.TryGetValue( - record.ServerGuid, - out ushort readyGeneration) - || readyGeneration != record.Generation + || !TryGetProjectionKey(record, out RuntimeEntityKey key) + || !_readyOwners.Contains(key) || !_shadows.Suspend(entity.Id)) { return; } - _suspendedShadowGenerationByGuid[record.ServerGuid] = record.Generation; + _suspendedShadowOwners.Add(key); } private bool IsCurrent( @@ -390,8 +363,42 @@ public sealed class LiveEntityPresentationController : IDisposable && ReferenceEquals(current.WorldEntity, entity); private bool IsPlacementActive(LiveEntityRecord record) => - _activePlacementGenerationByGuid.TryGetValue( - record.ServerGuid, - out ushort generation) - && generation == record.Generation; + TryGetProjectionKey(record, out RuntimeEntityKey key) + && _activePlacementOwners.Contains(key); + + private bool TryGetCurrentProjectionKey( + uint serverGuid, + out RuntimeEntityKey key) + { + if (_liveEntities.TryGetRecord( + serverGuid, + out LiveEntityRecord record)) + { + return TryGetProjectionKey(record, out key); + } + + key = default; + return false; + } + + private static bool TryGetProjectionKey( + LiveEntityRecord record, + out RuntimeEntityKey key) + { + if (record.ProjectionKey is { } projectionKey) + { + key = projectionKey; + return true; + } + + key = default; + return false; + } + + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + + "has no exact projection key."); } diff --git a/src/AcDream.App/World/LiveEntityProjectionStore.cs b/src/AcDream.App/World/LiveEntityProjectionStore.cs new file mode 100644 index 00000000..a6b37087 --- /dev/null +++ b/src/AcDream.App/World/LiveEntityProjectionStore.cs @@ -0,0 +1,313 @@ +using AcDream.Runtime.Entities; + +namespace AcDream.App.World; + +/// +/// App-only projection storage. RuntimeEntityDirectory remains the sole +/// server-GUID/incarnation/local-ID authority; every App sidecar exists only +/// after Runtime has issued its exact local-ID plus INSTANCE_TS key. +/// +internal sealed class LiveEntityProjectionStore +{ + private readonly RuntimeEntityDirectory _directory; + private readonly Dictionary _materialized = new(); + private readonly Dictionary _visible = new(); + private readonly Dictionary _teardown = new(); + private readonly Dictionary + _unmaterializedTeardown = new(ReferenceEqualityComparer.Instance); + private readonly List _materializedInRegistrationOrder = []; + private readonly TeardownRecordCollection _teardownRecords; + + public LiveEntityProjectionStore(RuntimeEntityDirectory directory) + { + _directory = directory ?? throw new ArgumentNullException(nameof(directory)); + _teardownRecords = new TeardownRecordCollection( + _teardown, + _unmaterializedTeardown); + } + + public int ActiveCount => _materializedInRegistrationOrder.Count; + public int MaterializedCount => _materialized.Count + _teardown.Count; + public int TeardownCount => _teardown.Count + _unmaterializedTeardown.Count; + public IReadOnlyList ActiveRecords => + _materializedInRegistrationOrder; + public IReadOnlyList Values => + _materializedInRegistrationOrder; + public IReadOnlyCollection MaterializedRecords => + _materialized.Values; + public IReadOnlyCollection VisibleRecords => _visible.Values; + public IReadOnlyCollection TeardownRecords => + _teardownRecords; + + /// + /// Creates the exact App sidecar after Runtime has claimed a local ID. + /// Publication is synchronous so no input or presentation callback moves + /// to a later frame. + /// + public LiveEntityRecord AddMaterializing(RuntimeEntityRecord canonical) + { + ArgumentNullException.ThrowIfNull(canonical); + if (!_directory.IsCurrent(canonical)) + { + throw new InvalidOperationException( + "An App projection sidecar can only be added for the current Runtime incarnation."); + } + RuntimeEntityKey key = canonical.Key + ?? throw new InvalidOperationException( + "Runtime must claim the local projection ID before App creates a sidecar."); + if (_materialized.ContainsKey(key)) + { + throw new InvalidOperationException( + $"Runtime entity 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} already has an App sidecar."); + } + + var record = new LiveEntityRecord(_directory, canonical) + { + ProjectionKey = key, + }; + _materialized.Add(key, record); + _materializedInRegistrationOrder.Add(record); + return record; + } + + public bool TryGetCurrent(uint serverGuid, out LiveEntityRecord record) + { + if (_directory.TryGetActive(serverGuid, out RuntimeEntityRecord canonical)) + return TryGet(canonical, out record); + + record = null!; + return false; + } + + public bool ContainsCurrent(uint serverGuid) => + TryGetCurrent(serverGuid, out _); + + public LiveEntityRecord? GetCurrentOrDefault(uint serverGuid) => + TryGetCurrent(serverGuid, out LiveEntityRecord record) ? record : null; + + public bool TryGet(RuntimeEntityRecord canonical, out LiveEntityRecord record) + { + if (canonical.Key is { } key + && _materialized.TryGetValue(key, out LiveEntityRecord? candidate) + && ReferenceEquals(candidate.Canonical, canonical)) + { + record = candidate; + return true; + } + + record = null!; + return false; + } + + public bool TryGet(RuntimeEntityKey key, out LiveEntityRecord record) => + _materialized.TryGetValue(key, out record!); + + public bool TryGetByLocalId(uint localEntityId, out LiveEntityRecord record) + { + if (_directory.TryGetByLocalId( + localEntityId, + out RuntimeEntityRecord canonical)) + { + return TryGet(canonical, out record); + } + + record = null!; + return false; + } + + public bool IsCurrent(LiveEntityRecord record) => + _directory.IsCurrent(record.Canonical) + && TryGet(record.Canonical, out LiveEntityRecord current) + && ReferenceEquals(current, record); + + public void SetVisible(LiveEntityRecord record, bool visible) + { + RuntimeEntityKey key = RequireProjectionKey(record); + if (!_materialized.TryGetValue(key, out LiveEntityRecord? materialized) + || !ReferenceEquals(materialized, record)) + { + throw new InvalidOperationException( + "Only a current exact App sidecar can change visibility."); + } + + if (visible) + _visible[key] = record; + else + _visible.Remove(key); + } + + public bool TryGetVisibleCurrent(uint serverGuid, out LiveEntityRecord record) + { + if (_directory.TryGetActive(serverGuid, out RuntimeEntityRecord canonical) + && canonical.Key is { } key + && _visible.TryGetValue(key, out LiveEntityRecord? visible) + && ReferenceEquals(visible.Canonical, canonical)) + { + record = visible; + return true; + } + + record = null!; + return false; + } + + public bool RemoveCurrent( + uint serverGuid, + out LiveEntityRecord? record) + { + if (!_directory.TryGetActive( + serverGuid, + out RuntimeEntityRecord canonical) + || !TryGet(canonical, out LiveEntityRecord sidecar) + || !RemoveActive(sidecar)) + { + record = null; + return false; + } + + record = sidecar; + return true; + } + + public bool RemoveCurrent(uint serverGuid) => + RemoveCurrent(serverGuid, out _); + + public bool RemoveActive(LiveEntityRecord record) + { + if (record.ProjectionKey is not { } key) + return false; + + _visible.Remove(key); + bool removed = _materialized.Remove( + key, + out LiveEntityRecord? materialized) + && ReferenceEquals(materialized, record); + if (!removed) + return false; + if (!_materializedInRegistrationOrder.Remove(record)) + { + throw new InvalidOperationException( + "The exact App sidecar was absent from materialization order."); + } + return true; + } + + public void RetainTeardown(LiveEntityRecord record) + { + if (record.ProjectionKey is { } key) + AddExact(_teardown, key, record); + else + AddExact(_unmaterializedTeardown, record.Canonical, record); + } + + public bool TryGetTeardown( + RuntimeEntityRecord canonical, + out LiveEntityRecord record) + { + if (canonical.Key is { } key + && _teardown.TryGetValue(key, out record!)) + { + return true; + } + foreach (LiveEntityRecord retained in _teardown.Values) + { + if (ReferenceEquals(retained.Canonical, canonical)) + { + record = retained; + return true; + } + } + return _unmaterializedTeardown.TryGetValue(canonical, out record!); + } + + public void ReleaseTeardown(LiveEntityRecord record) + { + if (record.ProjectionKey is { } key) + { + RemoveExact(_teardown, key, record); + record.ProjectionKey = null; + return; + } + + RemoveExact(_unmaterializedTeardown, record.Canonical, record); + } + + public bool IsRetainedTeardown(LiveEntityRecord record) => + TryGetTeardown(record.Canonical, out LiveEntityRecord retained) + && ReferenceEquals(retained, record); + + public void ClearConverged() + { + if (_materializedInRegistrationOrder.Count != 0 || TeardownCount != 0) + { + throw new InvalidOperationException( + "Projection storage cannot clear before active and teardown ownership converges."); + } + + _materialized.Clear(); + _visible.Clear(); + _teardown.Clear(); + _unmaterializedTeardown.Clear(); + } + + private static RuntimeEntityKey RequireProjectionKey(LiveEntityRecord record) => + record.ProjectionKey + ?? throw new InvalidOperationException( + $"Runtime entity 0x{record.ServerGuid:X8}/{record.Generation} has no App projection key."); + + private static void AddExact( + Dictionary destination, + TKey key, + LiveEntityRecord record) + where TKey : notnull + { + if (destination.TryGetValue(key, out LiveEntityRecord? retained) + && !ReferenceEquals(retained, record)) + { + throw new InvalidOperationException( + $"App teardown sidecar collision for 0x{record.ServerGuid:X8}/{record.Generation}."); + } + destination[key] = record; + } + + private static void RemoveExact( + Dictionary source, + TKey key, + LiveEntityRecord record) + where TKey : notnull + { + if (source.TryGetValue(key, out LiveEntityRecord? retained) + && ReferenceEquals(retained, record)) + { + source.Remove(key); + } + } + + private sealed class TeardownRecordCollection : + IReadOnlyCollection + { + private readonly Dictionary _keyed; + private readonly Dictionary _unmaterialized; + + public TeardownRecordCollection( + Dictionary keyed, + Dictionary unmaterialized) + { + _keyed = keyed; + _unmaterialized = unmaterialized; + } + + public int Count => _keyed.Count + _unmaterialized.Count; + + public IEnumerator GetEnumerator() + { + foreach (LiveEntityRecord record in _keyed.Values) + yield return record; + foreach (LiveEntityRecord record in _unmaterialized.Values) + yield return record; + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + } +} diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 3a2c3b23..b3567f4f 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -226,24 +226,8 @@ public sealed class LiveEntityRecord Canonical = canonical ?? throw new ArgumentNullException(nameof(canonical)); } - /// - /// 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; } + internal RuntimeEntityKey? ProjectionKey { get; set; } public uint ServerGuid => Canonical.ServerGuid; public ushort Generation => Canonical.Incarnation; public WorldSession.EntitySpawn Snapshot @@ -431,7 +415,8 @@ public sealed class LiveEntityRecord public readonly record struct LiveEntityRegistrationResult( InboundCreateResult Inbound, - LiveEntityRecord? Record, + RuntimeEntityRecord? Canonical, + LiveEntityRecord? Projection, bool LogicalRegistrationCreated, bool ReplacedExistingGeneration, Exception? PriorGenerationCleanupFailure = null); @@ -455,7 +440,7 @@ public readonly record struct LiveEntityRegistrationResult( /// CPhysicsObj::exit_world (0x00514E60). /// /// -public sealed class LiveEntityRuntime +public sealed class LiveEntityRuntime : ILiveEntityRadarSource { public const uint FirstLiveEntityId = RuntimeEntityDirectory.FirstLocalEntityId; public const uint LastLiveEntityId = RuntimeEntityDirectory.LastLocalEntityId; @@ -464,38 +449,19 @@ public sealed class LiveEntityRuntime private readonly ILiveEntityResourceLifecycle _resources; private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle; 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 - _teardownRecords = new(); - private readonly Dictionary _materializedWorldEntitiesByGuid = new(); - private readonly Dictionary _visibleWorldEntitiesByGuid = new(); - private readonly Dictionary _animationsByLocalId = new(); - private readonly Dictionary _remoteMotionByGuid = new(); + private readonly LiveEntityProjectionStore _projections; // Logical components survive retail's 25-second leave-visibility lifetime. // Frame loops must not scan those retained owners. These component-specific // indexes contain only records with a loaded, non-cellless spatial // projection. Hidden and Frozen remain members: their state controls what // each retail update path advances after the O(active) lookup. - private readonly Dictionary _spatialAnimationsByLocalId = new(); - private readonly Dictionary _spatialRemoteMotionByGuid = new(); - private readonly Dictionary _spatialProjectilesByGuid = new(); + private readonly Dictionary _spatialAnimations = new(); + private readonly Dictionary _spatialRemoteMotion = new(); + private readonly Dictionary _spatialProjectiles = new(); // Retail CPhysics::UseTime walks the ordinary object table, not a render- // animation component table. This index is the loaded/cell-backed root // workset; component-specific indexes remain lookup accelerators only. - private readonly Dictionary _spatialRootObjectsByGuid = new(); + private readonly Dictionary _spatialRootObjects = new(); private bool _isClearing; private bool _sessionClearPendingFinalization; private bool _isRegisteringResources; @@ -538,44 +504,65 @@ public sealed class LiveEntityRuntime _runtimeComponentLifecycle = runtimeComponentLifecycle ?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle)); _directory = new RuntimeEntityDirectory(firstLocalEntityId); - _recordsByGuid = new ActiveRecordView(_directory, _activeRecords); + _projections = new LiveEntityProjectionStore(_directory); _spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged; } 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 - /// physics mutation must use this stable view so streaming visibility can - /// never masquerade as logical destruction. - /// - public IReadOnlyDictionary MaterializedWorldEntities => - _materializedWorldEntitiesByGuid; - - /// - /// Currently visible top-level world projections. Attached children and - /// pending or Hidden projections are excluded from radar, picking, status, - /// and target candidate consumers. NoDraw alone suppresses the mesh but is - /// not a retail cell-visibility transition. - /// - public IReadOnlyDictionary WorldEntities => - _visibleWorldEntitiesByGuid; + public int MaterializedCount => _projections.MaterializedCount; + public IReadOnlyCollection Records => _projections.ActiveRecords; + public IReadOnlyCollection MaterializedRecords => + _projections.MaterializedRecords; + public IReadOnlyCollection VisibleRecords => + _projections.VisibleRecords; + internal IReadOnlyCollection CanonicalRecords => + _directory.ActiveRecords; public IReadOnlyDictionary Snapshots => _directory.Snapshots; - public IReadOnlyDictionary AnimationRuntimes => _animationsByLocalId; - public IReadOnlyDictionary RemoteMotionRuntimes => _remoteMotionByGuid; - public IReadOnlyDictionary SpatialAnimationRuntimes => - _spatialAnimationsByLocalId; - public IReadOnlyDictionary SpatialRemoteMotionRuntimes => - _spatialRemoteMotionByGuid; - internal IReadOnlyDictionary SpatialProjectileRuntimes => - _spatialProjectilesByGuid; - internal IReadOnlyDictionary SpatialRootObjects => - _spatialRootObjectsByGuid; + internal int AnimationRuntimeCount + { + get + { + int count = 0; + IReadOnlyList records = _projections.ActiveRecords; + for (int i = 0; i < records.Count; i++) + { + if (records[i].AnimationRuntime is not null) + count++; + } + return count; + } + } + internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count; + internal int SpatialRemoteMotionRuntimeCount => _spatialRemoteMotion.Count; + internal int SpatialProjectileRuntimeCount => _spatialProjectiles.Count; + internal int SpatialRootObjectCount => _spatialRootObjects.Count; public ParentAttachmentState ParentAttachments => _directory.ParentAttachments; + internal bool TryGetCanonical( + uint serverGuid, + out RuntimeEntityRecord canonical) => + _directory.TryGetActive(serverGuid, out canonical); + + internal bool TryGetProjection( + RuntimeEntityRecord canonical, + out LiveEntityRecord record) => + _projections.TryGet(canonical, out record); + + internal bool IsCurrentCanonical(RuntimeEntityRecord canonical) => + _directory.IsCurrent(canonical); + + internal bool IsCurrentCreateIntegration( + RuntimeEntityRecord canonical, + ulong expectedCreateIntegrationVersion) => + _directory.IsCurrent(canonical) + && canonical.CreateIntegrationVersion == expectedCreateIntegrationVersion; + + internal void SetHasPartArray( + RuntimeEntityRecord canonical, + bool value) => + _directory.SetHasPartArray(canonical, value); + /// /// Raised after a materialized projection enters or leaves the currently /// loaded spatial view. Logical resources remain owned by the record. @@ -594,19 +581,37 @@ public sealed class LiveEntityRuntime InboundCreateResult result = _directory.AcceptCreate(incoming); if (result.Disposition is CreateObjectTimestampDisposition.StaleGeneration) - return new LiveEntityRegistrationResult(result, null, false, false); + 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) { - if (_recordsByGuid.TryGetValue(incoming.Guid, out LiveEntityRecord? retained)) + if (_directory.TryGetActive( + incoming.Guid, + out RuntimeEntityRecord retainedCanonical)) { - retained.Snapshot = result.Snapshot; - retained.RefreshDerivedState(); - retained.AdvanceCreateAuthority(); - RefreshSpatialRuntimeIndexes(retained); - return new LiveEntityRegistrationResult(result, retained, false, false); + _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 @@ -620,7 +625,8 @@ public sealed class LiveEntityRuntime { return new LiveEntityRegistrationResult( result, - null, + Canonical: null, + Projection: null, false, false); } @@ -629,12 +635,28 @@ public sealed class LiveEntityRuntime // Normal session teardown clears both owners together. RuntimeEntityRecord recoveredCanonical = _directory.AddActive(result.Snapshot); - var recovered = new LiveEntityRecord(_directory, recoveredCanonical); - _recordsByGuid.Add(incoming.Guid, recovered); - return new LiveEntityRegistrationResult(result, recovered, true, false); + return new LiveEntityRegistrationResult( + result, + recoveredCanonical, + Projection: null, + LogicalRegistrationCreated: true, + ReplacedExistingGeneration: false); } - bool replaced = _recordsByGuid.Remove(incoming.Guid, out LiveEntityRecord? old); + 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); @@ -660,6 +682,10 @@ public sealed class LiveEntityRuntime _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 @@ -678,18 +704,22 @@ public sealed class LiveEntityRuntime return new LiveEntityRegistrationResult( SupersededCreateResult(), - _recordsByGuid.GetValueOrDefault(incoming.Guid), + _directory.TryGetActive( + incoming.Guid, + out RuntimeEntityRecord currentCanonical) + ? currentCanonical + : null, + _projections.GetCurrentOrDefault(incoming.Guid), false, replaced, tearDownFailure); } RuntimeEntityRecord canonical = _directory.AddActive(result.Snapshot); - var record = new LiveEntityRecord(_directory, canonical); - _recordsByGuid.Add(incoming.Guid, record); return new LiveEntityRegistrationResult( result, - record, + canonical, + Projection: null, true, replaced, tearDownFailure); @@ -705,7 +735,38 @@ public sealed class LiveEntityRuntime Func factory, LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World) { + if (!_directory.TryGetActive( + serverGuid, + out RuntimeEntityRecord canonical)) + { + return null; + } + + return MaterializeLiveEntity( + canonical, + fullCellId, + factory, + projectionKind, + initializeProjection: null, + out _); + } + + /// + /// Claims the canonical Runtime local ID, creates its exact-key App + /// sidecar, and synchronously constructs the projection. No sidecar exists + /// before this method crosses the local-ID claim boundary. + /// + internal WorldEntity? MaterializeLiveEntity( + RuntimeEntityRecord expectedCanonical, + uint fullCellId, + Func factory, + LiveEntityProjectionKind projectionKind, + Action? initializeProjection, + out LiveEntityRecord? record) + { + ArgumentNullException.ThrowIfNull(expectedCanonical); ArgumentNullException.ThrowIfNull(factory); + record = null; if (_isClearing || _sessionClearPendingFinalization || _logicalTeardownDepth != 0 @@ -714,12 +775,38 @@ public sealed class LiveEntityRuntime throw new InvalidOperationException( "A live entity cannot materialize inside an active logical-lifetime transition."); } - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) + if (!_directory.IsCurrent(expectedCanonical)) return null; + uint serverGuid = expectedCanonical.ServerGuid; + bool createdSidecar = false; + if (!_projections.TryGet(expectedCanonical, out record)) + { + uint localId = _directory.ClaimLocalId(expectedCanonical); + try + { + record = _projections.AddMaterializing(expectedCanonical); + createdSidecar = true; + initializeProjection?.Invoke(record); + } + catch + { + if (record is not null) + { + _projections.RemoveActive(record); + record.ProjectionKey = null; + record = null; + } + _directory.ReleaseLocalId(expectedCanonical); + throw; + } + } + if (record.WorldEntity is null) { - uint localId = _directory.ClaimLocalId(record.Canonical); + uint localId = expectedCanonical.LocalEntityId + ?? throw new InvalidOperationException( + "A materializing App sidecar lost its Runtime local ID."); WorldEntity entity; try { @@ -739,7 +826,13 @@ public sealed class LiveEntityRuntime } catch { - _directory.ReleaseLocalId(record.Canonical); + if (createdSidecar) + { + _projections.RemoveActive(record); + record.ProjectionKey = null; + _directory.ReleaseLocalId(expectedCanonical); + record = null; + } throw; } @@ -772,17 +865,20 @@ public sealed class LiveEntityRuntime if (rollbackError is null) { - _directory.ReleaseLocalId(record.Canonical); + _projections.RemoveActive(record); + record.ProjectionKey = null; + _directory.ReleaseLocalId(expectedCanonical); record.WorldEntity = null; + record = null; throw; } - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? active) - && ReferenceEquals(active, record)) + if (_directory.IsCurrent(expectedCanonical)) + _directory.RemoveActive(expectedCanonical); + if (_projections.RemoveActive(record)) { - _recordsByGuid.Remove(serverGuid); + RetainTeardownRecord(record); } - RetainTeardownRecord(record); throw new AggregateException( "Live entity resource registration and rollback both failed; " + "the partial owner was retained for teardown retry.", @@ -796,6 +892,14 @@ public sealed class LiveEntityRuntime } } + if (!_directory.IsCurrent(expectedCanonical) + || !_projections.TryGet(expectedCanonical, out LiveEntityRecord currentRecord) + || !ReferenceEquals(currentRecord, record)) + { + record = null; + return null; + } + record.ProjectionKind = projectionKind; if (projectionKind is LiveEntityProjectionKind.World) { @@ -808,13 +912,14 @@ public sealed class LiveEntityRuntime RefreshPresentation(record); WorldEntity materialized = record.WorldEntity!; if (!RebucketLiveEntity(serverGuid, fullCellId) - || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current) || !ReferenceEquals(current, record) || !ReferenceEquals(current.WorldEntity, materialized)) { // Visibility observers may replace this GUID while rebucketing. // A failed teardown can retain the displaced entity as a retry // tombstone; it is not the result of this materialization. + record = null; return null; } return materialized; @@ -823,14 +928,15 @@ public sealed class LiveEntityRuntime /// Changes spatial buckets only. No logical resource callbacks run. public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is not { } entity) return false; + RuntimeEntityKey key = RequireProjectionKey(record); bool wasProjected = record.IsSpatiallyProjected; bool wasVisible = record.IsSpatiallyVisible; - bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( - serverGuid, + bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( + key, out LiveEntityRecord? indexedRoot) && ReferenceEquals(indexedRoot, record); ulong projectionOperation = ++record.ProjectionMutationVersion; @@ -868,12 +974,8 @@ public sealed class LiveEntityRuntime runtimeNotificationFailure: null); return false; } - bool visible = _spatial.IsLiveEntityProjectionResident(serverGuid); + bool visible = _spatial.IsLiveEntityProjectionResident(entity.Id); record.IsSpatiallyVisible = visible; - if (record.ProjectionKind is LiveEntityProjectionKind.World) - _materializedWorldEntitiesByGuid[serverGuid] = entity; - else - _materializedWorldEntitiesByGuid.Remove(serverGuid); RefreshPresentation(record); if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) record.FullCellId = spatialCellOrLandblockId; @@ -950,12 +1052,13 @@ public sealed class LiveEntityRuntime /// public bool WithdrawLiveEntityProjection(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is null) return false; - bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( - serverGuid, + RuntimeEntityKey key = RequireProjectionKey(record); + bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( + key, out LiveEntityRecord? rootRecord) && ReferenceEquals(rootRecord, record); ulong objectClockEpoch = record.ObjectClockEpoch; @@ -963,7 +1066,7 @@ public sealed class LiveEntityRuntime Exception? spatialNotificationFailure = null; try { - _spatial.RemoveLiveEntityProjection(serverGuid); + _spatial.RemoveLiveEntityProjection(record.WorldEntity); } catch (AggregateException error) { @@ -986,8 +1089,7 @@ public sealed class LiveEntityRuntime // OnSpatialVisibilityChanged rejects the later duplicate against this // committed record state. bool spatialEdgeWasDeferred = record.IsSpatiallyVisible; - _materializedWorldEntitiesByGuid.Remove(serverGuid); - _visibleWorldEntitiesByGuid.Remove(serverGuid); + _projections.SetVisible(record, false); record.IsSpatiallyProjected = false; record.IsSpatiallyVisible = false; // GpuWorldState normally publishes the leave edge synchronously and @@ -1068,7 +1170,7 @@ public sealed class LiveEntityRuntime /// internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is null) { return false; @@ -1091,12 +1193,13 @@ public sealed class LiveEntityRuntime } var teardownKey = (delete.Guid, delete.InstanceSequence); LiveEntityRecord? record = null; + RuntimeEntityRecord? canonicalOnly = null; if (_directory.TryGetTeardown( teardownKey.Guid, teardownKey.InstanceSequence, out RuntimeEntityRecord retainedCanonical)) { - _teardownRecords.TryGetValue(retainedCanonical, out record); + _projections.TryGetTeardown(retainedCanonical, out record); } bool retryingAcceptedDelete = record is not null && (record.DeleteAcceptedForTeardown @@ -1115,11 +1218,28 @@ public sealed class LiveEntityRuntime // succeeded. A throwing resource callback can then be retried by // the same accepted Delete instead of orphaning its owner. LiveEntityRecord? retainedRegistrationFailure = record; - _recordsByGuid.Remove(delete.Guid, out LiveEntityRecord? activeRecord); - if (activeRecord is not null) + if (_directory.TryGetActive( + delete.Guid, + out RuntimeEntityRecord activeCanonical) + && activeCanonical.Incarnation == delete.InstanceSequence + && _directory.RemoveActive(activeCanonical)) { - record = activeRecord; - RetainTeardownRecord(record); + if (_projections.TryGet( + activeCanonical, + out LiveEntityRecord? activeRecord)) + { + if (!_projections.RemoveActive(activeRecord)) + { + throw new InvalidOperationException( + $"Exact App projection for 0x{delete.Guid:X8}/{delete.InstanceSequence} could not be retired."); + } + record = activeRecord; + RetainTeardownRecord(record); + } + else + { + canonicalOnly = activeCanonical; + } } else { @@ -1157,6 +1277,10 @@ public sealed class LiveEntityRuntime (failures ??= new List()).Add(error); } } + else if (canonicalOnly is not null) + { + TearDownCanonicalOnly(canonicalOnly); + } } finally { @@ -1168,7 +1292,7 @@ public sealed class LiveEntityRuntime // Persistence is GUID-scoped. End it only when the accepted // delete left no replacement incarnation or unfinished teardown // behind. - if (!_recordsByGuid.ContainsKey(delete.Guid) + if (!_directory.TryGetActive(delete.Guid, out _) && !HasPendingTeardown(delete.Guid)) { _spatial.ForgetLiveEntity(delete.Guid); @@ -1187,11 +1311,11 @@ public sealed class LiveEntityRuntime } public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) => - _recordsByGuid.TryGetValue(serverGuid, out record!); + _projections.TryGetCurrent(serverGuid, out record!); public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.WorldEntity is { } found) { entity = found; @@ -1208,18 +1332,53 @@ public sealed class LiveEntityRuntime /// Pending, attached, and Hidden projections are intentionally excluded. /// public bool TryGetInteractionEligibleEntity( + uint serverGuid, + out WorldEntity entity) + { + if (_projections.TryGetVisibleCurrent( + serverGuid, + out LiveEntityRecord record) + && record.WorldEntity is { } visible) + { + entity = visible; + return true; + } + + entity = null!; + return false; + } + + bool ILiveEntityRadarSource.TryGetMaterialized( uint serverGuid, out WorldEntity entity) => - _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out entity!); + TryGetWorldEntity(serverGuid, out entity); + + bool ILiveEntityRadarSource.TryGetVisible( + uint serverGuid, + out WorldEntity entity) => + TryGetInteractionEligibleEntity(serverGuid, out entity); + + void ILiveEntityRadarSource.CopyVisibleTo( + List> destination) + { + ArgumentNullException.ThrowIfNull(destination); + destination.Clear(); + foreach (LiveEntityRecord record in _projections.VisibleRecords) + { + destination.Add(new KeyValuePair( + record.ServerGuid, + record.WorldEntity!)); + } + } public bool TryGetInteractionEligibleRecord( uint serverGuid, out LiveEntityRecord record) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? found) - && found.WorldEntity is { } entity - && _visibleWorldEntitiesByGuid.TryGetValue(serverGuid, out WorldEntity? visible) - && ReferenceEquals(entity, visible)) + if (_projections.TryGetVisibleCurrent( + serverGuid, + out LiveEntityRecord found) + && found.WorldEntity is not null) { record = found; return true; @@ -1253,7 +1412,7 @@ public sealed class LiveEntityRuntime } public bool ContainsWorldEntity(uint serverGuid) => - _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + _projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.WorldEntity is not null; public bool TryGetServerGuid(uint localEntityId, out uint serverGuid) @@ -1270,9 +1429,19 @@ public sealed class LiveEntityRuntime return false; } + internal bool TryGetRecordByLocalEntityId( + uint localEntityId, + out LiveEntityRecord record) => + _projections.TryGetByLocalId(localEntityId, out record); + + internal bool TryGetRecord( + RuntimeEntityKey key, + out LiveEntityRecord record) => + _projections.TryGet(key, out record); + public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.LocalEntityId is { } found) { localEntityId = found; @@ -1286,29 +1455,55 @@ public sealed class LiveEntityRuntime public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is not { } entity) throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized."); if (!ReferenceEquals(runtime.Entity, entity)) throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity."); - if (record.AnimationRuntime is not null) - _animationsByLocalId.Remove(entity.Id); + _ = RequireProjectionKey(record); record.AnimationRuntime = runtime; - _animationsByLocalId[entity.Id] = runtime; RefreshSpatialRuntimeIndexes(record); } - public bool TryGetAnimationRuntime(uint localEntityId, out ILiveEntityAnimationRuntime runtime) => - _animationsByLocalId.TryGetValue(localEntityId, out runtime!); + public bool TryGetAnimationRuntime( + uint localEntityId, + out ILiveEntityAnimationRuntime runtime) + { + if (_projections.TryGetByLocalId( + localEntityId, + out LiveEntityRecord record) + && record.AnimationRuntime is { } found) + { + runtime = found; + return true; + } + + runtime = null!; + return false; + } public bool ClearAnimationRuntime(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.AnimationRuntime is null) return false; - if (record.LocalEntityId is { } localId) - _animationsByLocalId.Remove(localId); + record.AnimationRuntime = null; + RefreshSpatialRuntimeIndexes(record); + return true; + } + + internal bool ClearAnimationRuntime(LiveEntityRecord expectedRecord) + { + ArgumentNullException.ThrowIfNull(expectedRecord); + if (expectedRecord.AnimationRuntime is null + || expectedRecord.ProjectionKey is not { } key + || !_projections.TryGet(key, out LiveEntityRecord? record) + || !ReferenceEquals(record, expectedRecord)) + { + return false; + } + record.AnimationRuntime = null; RefreshSpatialRuntimeIndexes(record); return true; @@ -1325,7 +1520,7 @@ public sealed class LiveEntityRuntime Func factory) { ArgumentNullException.ThrowIfNull(factory); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is null) { throw new InvalidOperationException( @@ -1344,7 +1539,7 @@ public sealed class LiveEntityRuntime { PhysicsBody candidate = factory(record) ?? throw new InvalidOperationException("Physics-body factory returned null."); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current) || !ReferenceEquals(current, record) || current.WorldEntity is null) { @@ -1373,7 +1568,7 @@ public sealed class LiveEntityRuntime public void SetRemoteMotionRuntime(uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); if (record.RemoteMotionBindingInProgress) throw new InvalidOperationException( @@ -1392,7 +1587,6 @@ public sealed class LiveEntityRuntime // component seams are incarnation-bound and must never be rebound. candidateBody.State = record.FinalPhysicsState; SynchronizePhysicsBodyActiveState(record); - _remoteMotionByGuid[serverGuid] = runtime; RefreshSpatialRuntimeIndexes(record); return; } @@ -1423,11 +1617,8 @@ public sealed class LiveEntityRuntime PhysicsBody? expectedBody = record.PhysicsBody; ILiveEntityRemoteMotionRuntime? expectedRuntime = record.RemoteMotionRuntime; bool expectedPlacementContract = record.RequiresRemotePlacementRuntime; - bool expectedIndexPresent = _remoteMotionByGuid.TryGetValue( - serverGuid, - out ILiveEntityRemoteMotionRuntime? expectedIndexedRuntime); Func readPhysicsHost = () => - _recordsByGuid.TryGetValue(serverGuid, out var current) + _projections.TryGetCurrent(serverGuid, out var current) && ReferenceEquals(current, incarnation) && ReferenceEquals(current.RemoteMotionRuntime, runtime) ? current.PhysicsHost @@ -1438,7 +1629,7 @@ public sealed class LiveEntityRuntime Action writeCell = cellId => { if (cellId != 0 - && _recordsByGuid.TryGetValue(serverGuid, out var current) + && _projections.TryGetCurrent(serverGuid, out var current) && ReferenceEquals(current, incarnation) && ReferenceEquals(current.RemoteMotionRuntime, runtime)) { @@ -1471,20 +1662,13 @@ public sealed class LiveEntityRuntime cellConsumer.BindCanonicalCell(readCell, writeCell); } - bool indexUnchanged = _remoteMotionByGuid.TryGetValue( - serverGuid, - out ILiveEntityRemoteMotionRuntime? currentIndexedRuntime) - == expectedIndexPresent - && (!expectedIndexPresent - || ReferenceEquals(currentIndexedRuntime, expectedIndexedRuntime)); if (_directory.SessionLifetimeVersion != sessionVersion || CurrentLifetimeMutation(serverGuid) != lifetimeVersion - || !_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + || !_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current) || !ReferenceEquals(current, incarnation) || !ReferenceEquals(current.PhysicsBody, expectedBody) || !ReferenceEquals(current.RemoteMotionRuntime, expectedRuntime) - || current.RequiresRemotePlacementRuntime != expectedPlacementContract - || !indexUnchanged) + || current.RequiresRemotePlacementRuntime != expectedPlacementContract) { throw new InvalidOperationException( $"Live entity 0x{serverGuid:X8} changed incarnation or component ownership during remote-motion binding."); @@ -1496,7 +1680,6 @@ public sealed class LiveEntityRuntime record.PhysicsBody = candidateBody; candidateBody.State = record.FinalPhysicsState; SynchronizePhysicsBodyActiveState(record); - _remoteMotionByGuid[serverGuid] = runtime; RefreshSpatialRuntimeIndexes(record); } finally @@ -1514,7 +1697,7 @@ public sealed class LiveEntityRuntime uint serverGuid = expectedRecord.ServerGuid; if (host.Id != serverGuid) throw new ArgumentException("A physics host must match its live entity GUID.", nameof(host)); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || !ReferenceEquals(record, expectedRecord)) { throw new InvalidOperationException( @@ -1530,7 +1713,7 @@ public sealed class LiveEntityRuntime uint serverGuid, out AcDream.Core.Physics.Motion.IPhysicsObjHost host) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.PhysicsHost is { } existing) { host = existing; @@ -1541,15 +1724,28 @@ public sealed class LiveEntityRuntime return false; } - public bool TryGetRemoteMotionRuntime(uint serverGuid, out ILiveEntityRemoteMotionRuntime runtime) => - _remoteMotionByGuid.TryGetValue(serverGuid, out runtime!); + public bool TryGetRemoteMotionRuntime( + uint serverGuid, + out ILiveEntityRemoteMotionRuntime runtime) + { + if (_projections.TryGetCurrent( + serverGuid, + out LiveEntityRecord? record) + && record.RemoteMotionRuntime is { } found) + { + runtime = found; + return true; + } + + runtime = null!; + return false; + } public bool ClearRemoteMotionRuntime(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.RemoteMotionRuntime is null) return false; - _remoteMotionByGuid.Remove(serverGuid); record.RemoteMotionRuntime = null; RefreshSpatialRuntimeIndexes(record); return true; @@ -1558,7 +1754,7 @@ public sealed class LiveEntityRuntime public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is null) { throw new InvalidOperationException( @@ -1586,7 +1782,7 @@ public sealed class LiveEntityRuntime uint serverGuid, out ILiveEntityProjectileRuntime runtime) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.ProjectileRuntime is { } found) { runtime = found; @@ -1599,7 +1795,7 @@ public sealed class LiveEntityRuntime public bool ClearProjectileRuntime(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.ProjectileRuntime is null) return false; @@ -1611,7 +1807,7 @@ public sealed class LiveEntityRuntime public void SetEffectProfile(uint serverGuid, ILiveEntityEffectProfile profile) { ArgumentNullException.ThrowIfNull(profile); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException( $"Cannot bind an effect profile before live entity 0x{serverGuid:X8} exists."); record.EffectProfile = profile; @@ -1621,7 +1817,7 @@ public sealed class LiveEntityRuntime uint serverGuid, out ILiveEntityEffectProfile profile) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && record.EffectProfile is { } found) { profile = found; @@ -1638,11 +1834,16 @@ public sealed class LiveEntityRuntime public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _directory.TryApplyObjDesc(update, out accepted); - if (applied) + if (applied + && _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.Guid, accepted); - if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + _directory.RefreshSnapshot(canonical, accepted); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) record.AdvanceObjDescAuthority(); + else + _directory.AdvanceObjDescAuthority(canonical); } return applied; } @@ -1650,12 +1851,17 @@ public sealed class LiveEntityRuntime public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _directory.TryApplyPickup(update, out accepted); - if (applied) + if (applied + && _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.Guid, accepted); - if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + _directory.RefreshSnapshot(canonical, accepted); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); - ClearWorldCell(update.Guid); + else + _directory.AdvancePositionAuthority(canonical); + ClearWorldCell(canonical); ParentAttachments.EndChildProjection(update.Guid); } return applied; @@ -1664,11 +1870,16 @@ public sealed class LiveEntityRuntime public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted) { bool applied = _directory.TryApplyCreateParent(update, out accepted); - if (applied) + if (applied + && _directory.TryGetActive( + update.ChildGuid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.ChildGuid, accepted); - if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) + _directory.RefreshSnapshot(canonical, accepted); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); + else + _directory.AdvancePositionAuthority(canonical); } return applied; } @@ -1676,11 +1887,16 @@ public sealed class LiveEntityRuntime public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _directory.TryApplyParent(update, out accepted); - if (applied) + if (applied + && _directory.TryGetActive( + update.ChildGuid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.ChildGuid, accepted); - if (_recordsByGuid.TryGetValue(update.ChildGuid, out LiveEntityRecord? record)) + _directory.RefreshSnapshot(canonical, accepted); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) record.AdvancePositionAuthority(); + else + _directory.AdvancePositionAuthority(canonical); } return applied; } @@ -1696,8 +1912,13 @@ public sealed class LiveEntityRuntime relation.PlacementId, relation.ChildPositionSequence, out accepted); - if (committed) - RefreshRecord(relation.ChildGuid, accepted); + if (committed + && _directory.TryGetActive( + relation.ChildGuid, + out RuntimeEntityRecord canonical)) + { + _directory.RefreshSnapshot(canonical, accepted); + } return committed; } @@ -1718,6 +1939,21 @@ public sealed class LiveEntityRuntime return IsCurrentPositionAuthority(record, positionAuthorityVersion); } + 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; + } + public bool TryApplyMotion( WorldSession.EntityMotionUpdate update, bool retainPayload, @@ -1725,13 +1961,21 @@ public sealed class LiveEntityRuntime out AcceptedPhysicsTimestamps timestamps) { bool applied = _directory.TryApplyMotion(update, retainPayload, out accepted, out timestamps); - if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot)) - RefreshRecord(update.Guid, snapshot); + if (_directory.TryGetSnapshot(update.Guid, out WorldSession.EntitySpawn snapshot) + && _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical)) + { + _directory.RefreshSnapshot(canonical, snapshot); + } if (applied && retainPayload - && _recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + && _directory.TryGetActive(update.Guid, out canonical)) { - record.AdvanceMovementAuthority(); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) + record.AdvanceMovementAuthority(); + else + _directory.AdvanceMovementAuthority(canonical); } return applied; } @@ -1739,11 +1983,16 @@ public sealed class LiveEntityRuntime public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted) { bool applied = _directory.TryApplyVector(update, out accepted); - if (applied) + if (applied + && _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.Guid, accepted); - if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) + _directory.RefreshSnapshot(canonical, accepted); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) record.AdvanceVectorAuthority(); + else + _directory.AdvanceVectorAuthority(canonical); } return applied; } @@ -1758,14 +2007,17 @@ public sealed class LiveEntityRuntime { bool applied = _directory.TryApplyState(update, out accepted); transition = default; - if (applied) + if (applied + && _directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord canonical)) { - RefreshRecord(update.Guid, accepted); - if (_recordsByGuid.TryGetValue(update.Guid, out LiveEntityRecord? record)) - { - transition = record.ApplyRawPhysicsState(update.PhysicsState); + _directory.RefreshSnapshot(canonical, accepted); + transition = _directory.ApplyRawPhysicsState( + canonical, + update.PhysicsState); + if (_projections.TryGet(canonical, out LiveEntityRecord? record)) RefreshPresentation(record); - } } return applied; } @@ -1776,7 +2028,7 @@ public sealed class LiveEntityRuntime /// public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw) { - if (!_recordsByGuid.TryGetValue(childServerGuid, out LiveEntityRecord? record)) + if (!_projections.TryGetCurrent(childServerGuid, out LiveEntityRecord? record)) return false; record.SetChildNoDraw(noDraw); RefreshPresentation(record); @@ -1792,10 +2044,15 @@ public sealed class LiveEntityRuntime out WorldSession.EntitySpawn accepted, out AcceptedPhysicsTimestamps timestamps) { - bool wasCellLess = _recordsByGuid.TryGetValue( - update.Guid, - out LiveEntityRecord? beforePosition) - && (beforePosition.FullCellId == 0 + 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( @@ -1816,17 +2073,30 @@ public sealed class LiveEntityRuntime TeleportHookRequired = timestamps.TeleportAdvanced || wasCellLess, }; } - RefreshRecord(update.Guid, snapshot, refreshPosition: acceptedPosition); - if (acceptedPosition) + if (_directory.TryGetActive( + update.Guid, + out RuntimeEntityRecord acceptedCanonical)) { - if (_recordsByGuid.TryGetValue( - update.Guid, - out LiveEntityRecord? acceptedRecord) - && ReferenceEquals(acceptedRecord, beforePosition)) + _directory.RefreshSnapshot( + acceptedCanonical, + snapshot, + refreshPosition: acceptedPosition); + if (acceptedPosition + && ReferenceEquals(acceptedCanonical, beforeCanonical)) { - acceptedRecord.AdvancePositionAuthority(); + if (_projections.TryGet( + acceptedCanonical, + out LiveEntityRecord? acceptedRecord) + && ReferenceEquals(acceptedRecord, beforePosition)) + { + acceptedRecord.AdvancePositionAuthority(); + } + else + { + _directory.AdvancePositionAuthority(acceptedCanonical); + } + ParentAttachments.EndChildProjection(update.Guid); } - ParentAttachments.EndChildProjection(update.Guid); } } return known; @@ -1848,7 +2118,7 @@ public sealed class LiveEntityRuntime public RetailObjectClockDisposition GetRootObjectClockDisposition( uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.ProjectionKind is not LiveEntityProjectionKind.World || !record.IsSpatiallyProjected || !record.IsSpatiallyVisible @@ -1869,42 +2139,42 @@ public sealed class LiveEntityRuntime internal bool IsCurrentPositionAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.PositionAuthorityVersion == authorityVersion; internal bool IsCurrentStateAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.StateAuthorityVersion == authorityVersion; internal bool IsCurrentVectorAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.VectorAuthorityVersion == authorityVersion; internal bool IsCurrentVelocityAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.VelocityAuthorityVersion == authorityVersion; internal bool IsCurrentMovementAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.MovementAuthorityVersion == authorityVersion; internal bool IsCurrentObjDescAuthority( LiveEntityRecord record, ulong authorityVersion) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && current.ObjDescAuthorityVersion == authorityVersion; @@ -1917,9 +2187,9 @@ public sealed class LiveEntityRuntime { ArgumentNullException.ThrowIfNull(destination); destination.Clear(); - foreach ((uint serverGuid, LiveEntityRecord indexed) in _spatialRootObjectsByGuid) + foreach ((RuntimeEntityKey key, LiveEntityRecord indexed) in _spatialRootObjects) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + if (_projections.TryGet(key, out LiveEntityRecord? current) && ReferenceEquals(current, indexed) && HasSpatialRuntimeProjection(current)) { @@ -1930,7 +2200,8 @@ public sealed class LiveEntityRuntime internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) => IsCurrentRecord(record) - && _spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexed) + && record.ProjectionKey is { } key + && _spatialRootObjects.TryGetValue(key, out var indexed) && ReferenceEquals(indexed, record) && HasSpatialRuntimeProjection(record); @@ -1939,10 +2210,16 @@ public sealed class LiveEntityRuntime ILiveEntityAnimationRuntime animation) => IsCurrentSpatialRootObject(record) && ReferenceEquals(record.AnimationRuntime, animation) - && record.WorldEntity is { } entity - && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexed) + && record.ProjectionKey is { } key + && _spatialAnimations.TryGetValue(key, out var indexed) && ReferenceEquals(indexed, animation); + internal bool IsCurrentSpatialAnimation( + uint localEntityId, + ILiveEntityAnimationRuntime animation) => + _projections.TryGetByLocalId(localEntityId, out LiveEntityRecord record) + && IsCurrentSpatialAnimation(record, animation); + /// /// Returns whether is still the canonical /// animation component of this logical entity incarnation. Motion @@ -1967,13 +2244,15 @@ public sealed class LiveEntityRuntime { ArgumentNullException.ThrowIfNull(destination); destination.Clear(); - foreach ((uint localId, ILiveEntityAnimationRuntime animation) - in _spatialAnimationsByLocalId) + foreach ((RuntimeEntityKey key, ILiveEntityAnimationRuntime animation) + in _spatialAnimations) { if (animation is TAnimation typed) { destination.Add( - new KeyValuePair(localId, typed)); + new KeyValuePair( + key.LocalEntityId, + typed)); } } } @@ -1982,8 +2261,8 @@ public sealed class LiveEntityRuntime { ArgumentNullException.ThrowIfNull(destination); destination.Clear(); - foreach (uint localId in _spatialAnimationsByLocalId.Keys) - destination.Add(localId); + foreach (RuntimeEntityKey key in _spatialAnimations.Keys) + destination.Add(key.LocalEntityId); } /// @@ -1995,7 +2274,7 @@ public sealed class LiveEntityRuntime ILiveEntityRemoteMotionRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || !ReferenceEquals(record.RemoteMotionRuntime, runtime) || (record.FinalPhysicsState & PhysicsStateFlags.Static) != 0) { @@ -2056,7 +2335,7 @@ public sealed class LiveEntityRuntime if (!IsFinite(velocity) || (angularVelocity is { } omega && !IsFinite(omega)) || !double.IsFinite(currentTime) - || !_recordsByGuid.TryGetValue( + || !_projections.TryGetCurrent( record.ServerGuid, out LiveEntityRecord? current) || !ReferenceEquals(current, record) @@ -2101,9 +2380,10 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(destination); destination.Clear(); - foreach ((uint serverGuid, ILiveEntityRemoteMotionRuntime runtime) in _spatialRemoteMotionByGuid) + foreach ((RuntimeEntityKey key, ILiveEntityRemoteMotionRuntime runtime) + in _spatialRemoteMotion) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGet(key, out LiveEntityRecord? record) && ReferenceEquals(record.RemoteMotionRuntime, runtime) && HasSpatialRuntimeProjection(record)) { @@ -2117,7 +2397,8 @@ public sealed class LiveEntityRuntime ILiveEntityRemoteMotionRuntime runtime) => IsCurrentRecord(record) && ReferenceEquals(record.RemoteMotionRuntime, runtime) - && _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexed) + && record.ProjectionKey is { } key + && _spatialRemoteMotion.TryGetValue(key, out var indexed) && ReferenceEquals(indexed, runtime) && HasSpatialRuntimeProjection(record); @@ -2131,9 +2412,10 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(destination); destination.Clear(); - foreach ((uint serverGuid, ILiveEntityProjectileRuntime runtime) in _spatialProjectilesByGuid) + foreach ((RuntimeEntityKey key, ILiveEntityProjectileRuntime runtime) + in _spatialProjectiles) { - if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (_projections.TryGet(key, out LiveEntityRecord? record) && ReferenceEquals(record.ProjectileRuntime, runtime) && HasSpatialRuntimeProjection(record)) { @@ -2147,12 +2429,13 @@ public sealed class LiveEntityRuntime ILiveEntityProjectileRuntime runtime) => IsCurrentRecord(record) && ReferenceEquals(record.ProjectileRuntime, runtime) - && _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexed) + && record.ProjectionKey is { } key + && _spatialProjectiles.TryGetValue(key, out var indexed) && ReferenceEquals(indexed, runtime) && HasSpatialRuntimeProjection(record); public bool IsHidden(uint serverGuid) => - _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + _projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) && (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; /// @@ -2163,7 +2446,7 @@ public sealed class LiveEntityRuntime /// public bool TryMarkWorldSpawnPublished(uint serverGuid) { - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) || record.WorldEntity is null || record.ProjectionKind is not LiveEntityProjectionKind.World || record.WorldSpawnPublished) @@ -2350,21 +2633,31 @@ public sealed class LiveEntityRuntime List? failures = null; try { - foreach (LiveEntityRecord record in _recordsByGuid.Values.ToArray()) + foreach (RuntimeEntityRecord canonical in + _directory.ActiveRecords.ToArray()) { - if (!_recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) - || !ReferenceEquals(current, record)) + if (!_directory.RemoveActive(canonical)) continue; - _recordsByGuid.Remove(record.ServerGuid); - RetainTeardownRecord(record); + if (_projections.TryGet( + canonical, + out LiveEntityRecord? record)) + { + if (!_projections.RemoveActive(record)) + { + throw new InvalidOperationException( + $"Exact App projection for 0x{record.ServerGuid:X8}/{record.Generation} could not be retired during session clear."); + } + RetainTeardownRecord(record); + } + else + { + TearDownCanonicalOnly(canonical); + } } - foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) + foreach (LiveEntityRecord record in _projections.TeardownRecords.ToArray()) { - if (!_teardownRecords.TryGetValue( - record.Canonical, - out LiveEntityRecord? retained) - || !ReferenceEquals(retained, record)) + if (!_projections.IsRetainedTeardown(record)) { continue; } @@ -2404,7 +2697,7 @@ public sealed class LiveEntityRuntime /// public int RetryPendingTeardowns() { - if (_teardownRecords.Count == 0 + if (_projections.TeardownCount == 0 || _isClearing || _isRegisteringResources || _logicalTeardownDepth != 0) @@ -2412,12 +2705,9 @@ public sealed class LiveEntityRuntime int completed = 0; List? failures = null; - foreach (LiveEntityRecord record in _teardownRecords.Values.ToArray()) + foreach (LiveEntityRecord record in _projections.TeardownRecords.ToArray()) { - if (!_teardownRecords.TryGetValue( - record.Canonical, - out LiveEntityRecord? retained) - || !ReferenceEquals(retained, record)) + if (!_projections.IsRetainedTeardown(record)) { continue; } @@ -2433,7 +2723,7 @@ public sealed class LiveEntityRuntime TearDownRecord(record); ReleaseTeardownRecord(record); completed++; - if (!_recordsByGuid.ContainsKey(record.ServerGuid) + if (!_directory.TryGetActive(record.ServerGuid, out _) && !HasPendingTeardown(record.ServerGuid) && !_directory.TryGetSnapshot(record.ServerGuid, out _)) { @@ -2461,10 +2751,9 @@ public sealed class LiveEntityRuntime WorldSession.EntitySpawn accepted, bool refreshPosition = false) { - if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) + if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)) return; - record.Snapshot = accepted; - record.RefreshDerivedState(refreshPosition); + _directory.RefreshSnapshot(canonical, accepted, refreshPosition); } private static InboundCreateResult SupersededCreateResult() => new( @@ -2479,11 +2768,17 @@ public sealed class LiveEntityRuntime private ulong CurrentLifetimeMutation(uint serverGuid) => _directory.CurrentLifetimeMutation(serverGuid); + private static RuntimeEntityKey RequireProjectionKey( + LiveEntityRecord record) => + record.ProjectionKey ?? record.Canonical.Key + ?? throw new InvalidOperationException( + $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} has no materialized projection key."); + private bool IsCurrentProjectionOperation( uint serverGuid, LiveEntityRecord record, ulong projectionOperation) => - _recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record) && record.ProjectionMutationVersion == projectionOperation; @@ -2508,10 +2803,23 @@ public sealed class LiveEntityRuntime private void ClearWorldCell(uint guid) { - if (!_recordsByGuid.TryGetValue(guid, out LiveEntityRecord? record)) + if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical)) return; - bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( - guid, + 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 @@ -2523,13 +2831,12 @@ public sealed class LiveEntityRuntime record.SuspendObjectClock(); SynchronizePhysicsBodyActiveState(record); } - record.FullCellId = 0; - record.CanonicalLandblockId = 0; + _directory.SetFullCell(canonical, 0, 0); RefreshSpatialRuntimeIndexes(record); } internal bool IsCurrentRecord(LiveEntityRecord record) => - _recordsByGuid.TryGetValue(record.ServerGuid, out LiveEntityRecord? current) + _projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current) && ReferenceEquals(current, record); private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) => @@ -2543,102 +2850,119 @@ public sealed class LiveEntityRuntime { bool current = IsCurrentRecord(record); bool spatial = current && HasSpatialRuntimeProjection(record); + if (record.ProjectionKey is not { } key) + { + if (record.WorldEntity is not null) + { + throw new InvalidOperationException( + $"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key."); + } + return; + } if (spatial) { - _spatialRootObjectsByGuid[record.ServerGuid] = record; + _spatialRootObjects[key] = record; } else if (current - || (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot) + || (_spatialRootObjects.TryGetValue(key, out var indexedRoot) && ReferenceEquals(indexedRoot, record))) { - _spatialRootObjectsByGuid.Remove(record.ServerGuid); + _spatialRootObjects.Remove(key); } - if (record.WorldEntity is { } entity) + if (record.WorldEntity is not null) { if (spatial && record.AnimationRuntime is { } animation) { - _spatialAnimationsByLocalId[entity.Id] = animation; + _spatialAnimations[key] = animation; } else if (current - || (record.AnimationRuntime is { } retainedAnimation - && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) - && ReferenceEquals(indexedAnimation, retainedAnimation))) + || (record.AnimationRuntime is { } retainedAnimation + && _spatialAnimations.TryGetValue(key, out var indexedAnimation) + && ReferenceEquals(indexedAnimation, retainedAnimation))) { - _spatialAnimationsByLocalId.Remove(entity.Id); + _spatialAnimations.Remove(key); } } if (spatial && record.RemoteMotionRuntime is { } remote) { - _spatialRemoteMotionByGuid[record.ServerGuid] = remote; + _spatialRemoteMotion[key] = remote; } else if (current || (record.RemoteMotionRuntime is { } retainedRemote - && _spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote) - && ReferenceEquals(indexedRemote, retainedRemote))) + && _spatialRemoteMotion.TryGetValue(key, out var indexedRemote) + && ReferenceEquals(indexedRemote, retainedRemote))) { - _spatialRemoteMotionByGuid.Remove(record.ServerGuid); + _spatialRemoteMotion.Remove(key); } if (spatial && record.ProjectileRuntime is { } projectile) { - _spatialProjectilesByGuid[record.ServerGuid] = projectile; + _spatialProjectiles[key] = projectile; } else if (current || (record.ProjectileRuntime is { } retainedProjectile - && _spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile) - && ReferenceEquals(indexedProjectile, retainedProjectile))) + && _spatialProjectiles.TryGetValue(key, out var indexedProjectile) + && ReferenceEquals(indexedProjectile, retainedProjectile))) { - _spatialProjectilesByGuid.Remove(record.ServerGuid); + _spatialProjectiles.Remove(key); } } private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record) { - if (_spatialRootObjectsByGuid.TryGetValue(record.ServerGuid, out var indexedRoot) + if (record.ProjectionKey is not { } key) + return; + + if (_spatialRootObjects.TryGetValue(key, out var indexedRoot) && ReferenceEquals(indexedRoot, record)) { - _spatialRootObjectsByGuid.Remove(record.ServerGuid); + _spatialRootObjects.Remove(key); } - if (record.WorldEntity is { } entity - && _spatialAnimationsByLocalId.TryGetValue(entity.Id, out var indexedAnimation) + if (record.WorldEntity is not null + && _spatialAnimations.TryGetValue(key, out var indexedAnimation) && ReferenceEquals(indexedAnimation, record.AnimationRuntime)) { - _spatialAnimationsByLocalId.Remove(entity.Id); + _spatialAnimations.Remove(key); } - if (_spatialRemoteMotionByGuid.TryGetValue(record.ServerGuid, out var indexedRemote) + if (_spatialRemoteMotion.TryGetValue(key, out var indexedRemote) && ReferenceEquals(indexedRemote, record.RemoteMotionRuntime)) { - _spatialRemoteMotionByGuid.Remove(record.ServerGuid); + _spatialRemoteMotion.Remove(key); } - if (_spatialProjectilesByGuid.TryGetValue(record.ServerGuid, out var indexedProjectile) + if (_spatialProjectiles.TryGetValue(key, out var indexedProjectile) && ReferenceEquals(indexedProjectile, record.ProjectileRuntime)) { - _spatialProjectilesByGuid.Remove(record.ServerGuid); + _spatialProjectiles.Remove(key); } } - private void OnSpatialVisibilityChanged(uint serverGuid, bool visible) + private void OnSpatialVisibilityChanged(uint localEntityId, bool visible) { // GpuWorldState serializes re-entrant visibility callbacks. A callback // may delete an incarnation and materialize the same server GUID before // an older queued edge drains. Apply an edge only while it still // matches current spatial truth; otherwise it belongs to the displaced // projection and must not mutate the replacement generation. - if (_spatial.IsLiveEntityProjectionResident(serverGuid) != visible) + if (_spatial.IsLiveEntityProjectionResident(localEntityId) != visible) return; - if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) - || record.WorldEntity is not { } entity) + if (!_projections.TryGetByLocalId( + localEntityId, + out LiveEntityRecord? record) + || record.WorldEntity is not { } entity + || entity.Id != localEntityId) return; + uint serverGuid = record.ServerGuid; bool wasVisible = record.IsSpatiallyVisible; - bool wasOrdinaryRoot = _spatialRootObjectsByGuid.TryGetValue( - serverGuid, + RuntimeEntityKey key = RequireProjectionKey(record); + bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( + key, out LiveEntityRecord? indexedRoot) && ReferenceEquals(indexedRoot, record); record.IsSpatiallyVisible = visible; @@ -2715,42 +3039,41 @@ public sealed class LiveEntityRuntime && record.ProjectionKind is LiveEntityProjectionKind.World && (state & PhysicsStateFlags.Hidden) == 0; if (interactionVisible) - _visibleWorldEntitiesByGuid[record.ServerGuid] = entity; + _projections.SetVisible(record, true); else - _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + _projections.SetVisible(record, false); } private void RetainTeardownRecord(LiveEntityRecord record) { _directory.RetainTeardown(record.Canonical); - if (_teardownRecords.TryGetValue( - record.Canonical, - out LiveEntityRecord? retained) - && !ReferenceEquals(retained, record)) + _projections.RetainTeardown(record); + } + + private void TearDownCanonicalOnly(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); + try { - throw new InvalidOperationException( - $"Live entity teardown tombstone collision for 0x{record.ServerGuid:X8} generation {record.Generation}."); + _directory.SetPhysicsHost(canonical, null); + _directory.SetPhysicsBody(canonical, null); + _directory.SetPhysicsBodyAcquisitionInProgress(canonical, false); + _directory.SetHasPartArray(canonical, false); + _directory.ReleaseLocalId(canonical); } - _teardownRecords[record.Canonical] = record; - if (record.WorldEntity is { } entity - && _visibleWorldEntitiesByGuid.TryGetValue( - record.ServerGuid, - out WorldEntity? visible) - && ReferenceEquals(visible, entity)) + finally { - _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); + _directory.ReleaseTeardown(canonical); } } private void ReleaseTeardownRecord(LiveEntityRecord record) { - if (_teardownRecords.TryGetValue( - record.Canonical, - out LiveEntityRecord? retained) - && ReferenceEquals(retained, record)) - { - _teardownRecords.Remove(record.Canonical); - } + _projections.ReleaseTeardown(record); _directory.ReleaseTeardown(record.Canonical); CompleteSessionClearIfConverged(); } @@ -2766,14 +3089,11 @@ public sealed class LiveEntityRuntime return; } - _materializedWorldEntitiesByGuid.Clear(); - _visibleWorldEntitiesByGuid.Clear(); - _animationsByLocalId.Clear(); - _remoteMotionByGuid.Clear(); - _spatialAnimationsByLocalId.Clear(); - _spatialRemoteMotionByGuid.Clear(); - _spatialProjectilesByGuid.Clear(); - _spatialRootObjectsByGuid.Clear(); + _spatialAnimations.Clear(); + _spatialRemoteMotion.Clear(); + _spatialProjectiles.Clear(); + _spatialRootObjects.Clear(); + _projections.ClearConverged(); _spatial.ClearLiveEntityLifetimeState(); _sessionClearPendingFinalization = false; } @@ -2835,31 +3155,12 @@ public sealed class LiveEntityRuntime failures); } - if (record.WorldEntity is { } finalizedEntity) + if (record.WorldEntity is not null) { + _ = RequireProjectionKey(record); _directory.ReleaseLocalId(record.Canonical); - if (_materializedWorldEntitiesByGuid.TryGetValue( - record.ServerGuid, - out WorldEntity? materialized) - && ReferenceEquals(materialized, finalizedEntity)) - { - _materializedWorldEntitiesByGuid.Remove(record.ServerGuid); - } - if (_visibleWorldEntitiesByGuid.TryGetValue( - record.ServerGuid, - out WorldEntity? visible) - && ReferenceEquals(visible, finalizedEntity)) - { - _visibleWorldEntitiesByGuid.Remove(record.ServerGuid); - } - _animationsByLocalId.Remove(finalizedEntity.Id); } - if (_remoteMotionByGuid.TryGetValue(record.ServerGuid, out var remote) - && ReferenceEquals(remote, record.RemoteMotionRuntime)) - { - _remoteMotionByGuid.Remove(record.ServerGuid); - } record.AnimationRuntime = null; record.RemoteMotionRuntime = null; record.PhysicsHost = null; @@ -2887,79 +3188,4 @@ public sealed class LiveEntityRuntime } } - /// - /// 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.App/World/LiveEntityRuntimeTeardownController.cs b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs index cd79d305..e57f15cc 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs @@ -106,11 +106,6 @@ internal sealed class LiveEntityRuntimeTeardownController { LiveEntityRuntime runtime = _runtime!; uint serverGuid = record.ServerGuid; - var incarnation = new LiveEntityIncarnationCleanup( - record, - guid => runtime.TryGetRecord(guid, out LiveEntityRecord current) - ? current - : null); var cleanups = new List { () => _presentation!.Forget(record), @@ -152,17 +147,17 @@ internal sealed class LiveEntityRuntimeTeardownController cleanups.Add(physicsHost.NotifyExitWorld); } - cleanups.Add(() => _animations.Remove(existingEntity.Id)); + cleanups.Add(() => _animations.Remove(record)); cleanups.Add(() => _classification!.InvalidateEntity(existingEntity.Id)); - cleanups.Add(() => incarnation.RunIfNoReplacement( - () => _remoteMovementObservations!.Remove(serverGuid))); + if (record.ProjectionKey is { } projectionKey) + cleanups.Add(() => _remoteMovementObservations!.Remove(projectionKey)); cleanups.Add(() => _translucencyFades!.ClearEntity(existingEntity.Id)); cleanups.Add(() => _projectionWithdrawal!.LeaveWorld( record, _identity!.ServerGuid)); cleanups.Add(() => _children!.OnLogicalTeardown(record)); cleanups.Add(() => _shadows!.Deregister(existingEntity.Id)); - cleanups.Add(() => _lights!.Forget(existingEntity.Id)); + cleanups.Add(() => _lights!.Forget(record)); return new LiveEntityTeardownPlan(cleanups); } } diff --git a/src/AcDream.App/World/LiveEntityRuntimeViews.cs b/src/AcDream.App/World/LiveEntityRuntimeViews.cs index 0ce8cef0..8e24c308 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeViews.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeViews.cs @@ -15,9 +15,7 @@ public sealed class LiveEntityAnimationRuntimeView internal LiveEntityAnimationRuntimeView(ILiveEntityRuntimeSource runtime) => _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); - public int Count => _runtime.Current?.SpatialAnimationRuntimes.Count ?? 0; - public IEnumerable Keys => - _runtime.Current?.SpatialAnimationRuntimes.Keys ?? Array.Empty(); + public int Count => _runtime.Current?.SpatialAnimationRuntimeCount ?? 0; public TAnimation this[uint localEntityId] { @@ -52,6 +50,12 @@ public sealed class LiveEntityAnimationRuntimeView && runtime.ClearAnimationRuntime(guid); } + internal bool Remove(LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return _runtime.Current?.ClearAnimationRuntime(record) == true; + } + /// Copies only currently resident local IDs without boxing a /// dictionary-key enumerator. public void CopySpatialIdsTo(HashSet destination) @@ -110,10 +114,9 @@ public sealed class LiveEntityAnimationRuntimeView while (++_index < _snapshot.Count) { KeyValuePair pair = _snapshot[_index]; - if (_runtime?.SpatialAnimationRuntimes.TryGetValue( + if (_runtime?.IsCurrentSpatialAnimation( pair.Key, - out ILiveEntityAnimationRuntime? indexed) == true - && ReferenceEquals(indexed, pair.Value)) + pair.Value) == true) { Current = pair; return true; diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index fdd57529..39cac4af 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -7,6 +7,7 @@ using AcDream.App.Net; using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.App.World; using AcDream.Core.Net; using AcDream.Core.World; using AcDream.UI.Abstractions; @@ -91,7 +92,7 @@ public sealed class InteractionUiRuntimeSourcesTests var provider = new RadarSnapshotProvider( new AcDream.Core.Items.ClientObjectTable(), - static () => new Dictionary(), + EmptyRadarSource.Instance, static () => new Dictionary(), static () => 0u, static () => 0f, @@ -253,6 +254,29 @@ public sealed class InteractionUiRuntimeSourcesTests } } + private sealed class EmptyRadarSource : ILiveEntityRadarSource + { + public static EmptyRadarSource Instance { get; } = new(); + + public bool TryGetMaterialized( + uint serverGuid, + out WorldEntity entity) + { + entity = null!; + return false; + } + + public bool TryGetVisible(uint serverGuid, out WorldEntity entity) + { + entity = null!; + return false; + } + + public void CopyVisibleTo( + List> destination) => + destination.Clear(); + } + private static T Stub() where T : class => (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); } diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 27b0bc10..84c4161e 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -501,13 +501,15 @@ public sealed class SelectionInteractionControllerTests public void OldRemovalClearsCapturedPickupButDoesNotClearReplacementSelection() { var h = new Harness(); - h.SetApproach(closeRange: true, localEntityId: 101u); + var oldRecord = LiveEntityTestFixture.CreateExactProjectionRecord( + Spawn(Target, instance: 1)); + uint localEntityId = oldRecord.LocalEntityId!.Value; + h.SetApproach(closeRange: true, localEntityId: localEntityId); h.Selection.Select(Target, SelectionChangeSource.World); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); - var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1)); oldRecord.WorldEntity = new WorldEntity { - Id = 101u, + Id = localEntityId, ServerGuid = Target, SourceGfxObjOrSetupId = 0x0200_0001u, Position = Vector3.Zero, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs index e0648148..44c8d55a 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityCollisionBuilderTests.cs @@ -33,10 +33,9 @@ public sealed class LiveEntityCollisionBuilderTests scale: 2f, itemType: (uint)ItemType.Creature, descriptionFlags: 0x28u); - var record = new LiveEntityRecord(spawn) - { - FinalPhysicsState = PhysicsStateFlags.Hidden | PhysicsStateFlags.Static, - }; + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); + record.FinalPhysicsState = + PhysicsStateFlags.Hidden | PhysicsStateFlags.Static; WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = Builder(); @@ -65,7 +64,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(part); WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -90,7 +89,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -115,7 +114,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -145,7 +144,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -178,7 +177,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -243,7 +242,7 @@ public sealed class LiveEntityCollisionBuilderTests var setup = new Setup(); setup.Parts.Add(basePart); WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; var builder = new LiveEntityCollisionBuilder( @@ -277,7 +276,7 @@ public sealed class LiveEntityCollisionBuilderTests public void ShapelessSetup_ProducesNoRegistration() { WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var record = new LiveEntityRecord(spawn); + var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); WorldEntity entity = Entity(); record.WorldEntity = entity; @@ -294,8 +293,9 @@ public sealed class LiveEntityCollisionBuilderTests public void Build_RejectsDifferentRecordIncarnation() { WorldSession.EntitySpawn spawn = Spawn(scale: 1f); - var expected = new LiveEntityRecord(spawn); - var other = new LiveEntityRecord(spawn with { InstanceSequence = 2 }); + var expected = LiveEntityTestFixture.CreateExactProjectionRecord(spawn); + var other = LiveEntityTestFixture.CreateExactProjectionRecord( + spawn with { InstanceSequence = 2 }); WorldEntity entity = Entity(); expected.WorldEntity = entity; other.WorldEntity = entity; diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs index 3c256cca..431d285a 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -17,7 +17,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Motion_StrictFreshnessAndWrapAreOwnedBeforePresentation() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 7, movement: 0xFFFE)); + Register(runtime, Spawn(Guid, instance: 7, movement: 0xFFFE)); var published = new List(); var gate = new LiveEntityInboundAuthorityGate( runtime, @@ -52,7 +52,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void AutonomousLocalMotion_ConsumesTimestampButDoesNotPublishPayload() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 1)); + Register(runtime, Spawn(Guid, instance: 1)); int publishCount = 0; var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => publishCount++); @@ -72,7 +72,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Vector_InvalidPayloadDoesNotConsumeSequenceAndDuplicateIsRejected() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 2)); + Register(runtime, Spawn(Guid, instance: 2)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); var update = new VectorUpdate.Parsed( Guid, @@ -94,7 +94,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Vector_WrappedSequenceIsAccepted() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 2, vector: 0xFFFE)); + Register(runtime, Spawn(Guid, instance: 2, vector: 0xFFFE)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); Assert.True(gate.TryAcceptVector( @@ -112,7 +112,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void State_EqualIsRejectedAndWrappedSequenceIsAccepted() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 3, state: 0xFFFE)); + Register(runtime, Spawn(Guid, instance: 3, state: 0xFFFE)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); Assert.False(gate.TryAcceptState( @@ -149,7 +149,7 @@ public sealed class LiveEntityInboundAuthorityGateTests Assert.Equal(0xAABB0001u, gate.LastLivePlayerLandblockId); Assert.Equal(0, publishCount); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 4, position: 1)); + Register(runtime, Spawn(Guid, instance: 4, position: 1)); Assert.True(gate.TryAcceptPosition( update, Guid, @@ -169,7 +169,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Position_InvalidPayloadDoesNotConsumeSequence() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1)); + Register(runtime, Spawn(Guid, instance: 5, position: 1)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); WorldSession.EntityPositionUpdate update = Position(Guid, 5, 2, 0x01010001u); @@ -185,7 +185,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Position_WrappedSequenceIsAccepted() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 0xFFFE)); + Register(runtime, Spawn(Guid, instance: 5, position: 0xFFFE)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); Assert.True(gate.TryAcceptPosition( @@ -201,7 +201,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, vector: 1)); + Register(runtime, Spawn(Guid, instance: 5, vector: 1)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); Assert.True(gate.TryAcceptVector( new VectorUpdate.Parsed(Guid, Vector3.One, Vector3.Zero, 5, 2), @@ -220,7 +220,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void State_NewerSameIncarnationPacketInvalidatesCapturedAuthority() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, state: 1)); + Register(runtime, Spawn(Guid, instance: 5, state: 1)); var gate = new LiveEntityInboundAuthorityGate(runtime, (_, _) => { }); Assert.True(gate.TryAcceptState( new SetState.Parsed(Guid, (uint)PhysicsStateFlags.Hidden, 5, 2), @@ -238,7 +238,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Motion_PublisherReplacementInvalidatesCapturedIncarnation() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 6)); + Register(runtime, Spawn(Guid, instance: 6)); var gate = new LiveEntityInboundAuthorityGate( runtime, (_, _) => runtime.RegisterLiveEntity(Spawn(Guid, instance: 7))); @@ -249,15 +249,17 @@ public sealed class LiveEntityInboundAuthorityGateTests out _, out bool timestampAccepted)); Assert.True(timestampAccepted); - Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord replacement)); - Assert.Equal((ushort)7, replacement.Generation); + Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord replacement)); + Assert.Equal((ushort)7, replacement.Incarnation); + Assert.Null(replacement.LocalEntityId); + Assert.False(runtime.TryGetRecord(Guid, out _)); } [Fact] public void Position_PublisherNewerChannelInvalidatesCapturedAuthority() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 8, position: 1)); + Register(runtime, Spawn(Guid, instance: 8, position: 1)); var nested = Position(Guid, 8, 3, 0x01010002u); LiveEntityInboundAuthorityGate? gate = null; gate = new LiveEntityInboundAuthorityGate( @@ -286,7 +288,7 @@ public sealed class LiveEntityInboundAuthorityGateTests public void Position_PublisherNewerVectorPreservesIndependentPositionAuthority() { LiveEntityRuntime runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(Guid, instance: 9, position: 1, vector: 1)); + Register(runtime, Spawn(Guid, instance: 9, position: 1, vector: 1)); var gate = new LiveEntityInboundAuthorityGate( runtime, (_, _) => runtime.TryApplyVector( @@ -317,6 +319,11 @@ public sealed class LiveEntityInboundAuthorityGateTests new GpuWorldState(), new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + private static LiveEntityRecord Register( + LiveEntityRuntime runtime, + WorldSession.EntitySpawn spawn) => + runtime.RegisterAndMaterializeProjection(spawn); + private static WorldSession.EntityMotionUpdate Motion( ushort instance, ushort movement, diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs index 040c23c9..29b11900 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityOrdinaryPhysicsUpdaterTests.cs @@ -26,10 +26,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests var live = new LiveEntityRuntime( spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!; - WorldEntity entity = live.MaterializeLiveEntity( - Guid, - SourceCell, + LiveEntityRecord record = live.RegisterAndMaterializeProjection( + Spawn(), id => new WorldEntity { Id = id, @@ -39,7 +37,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = SourceCell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); var remote = new AcDream.App.Physics.RemoteMotion { @@ -94,10 +93,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests var live = new LiveEntityRuntime( spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - LiveEntityRecord record = live.RegisterLiveEntity(Spawn()).Record!; - WorldEntity entity = live.MaterializeLiveEntity( - Guid, - SourceCell, + LiveEntityRecord record = live.RegisterAndMaterializeProjection( + Spawn(), id => new WorldEntity { Id = id, @@ -107,7 +104,8 @@ public sealed class LiveEntityOrdinaryPhysicsUpdaterTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = SourceCell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); var remote = new AcDream.App.Physics.RemoteMotion { CellId = SourceCell, diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs index 9fe8d5fa..ae1e64d8 100644 --- a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -243,18 +243,18 @@ public sealed class ProjectileControllerTests Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1)); Assert.True(record.PhysicsBody!.InWorld); Assert.True(record.PhysicsBody.IsActive); - Assert.Single(fixture.Live.SpatialProjectileRuntimes); + Assert.Equal(1, fixture.Live.SpatialProjectileRuntimeCount); fixture.Spatial.RemoveLandblock(0x0101FFFFu); Assert.False(record.IsSpatiallyVisible); - Assert.Empty(fixture.Live.SpatialProjectileRuntimes); + Assert.Equal(0, fixture.Live.SpatialProjectileRuntimeCount); Assert.False(record.PhysicsBody.InWorld); Assert.False(record.PhysicsBody.IsActive); Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); - Assert.Single(fixture.Live.SpatialProjectileRuntimes); + Assert.Equal(1, fixture.Live.SpatialProjectileRuntimeCount); Assert.True(record.PhysicsBody.InWorld); Assert.True(record.PhysicsBody.IsActive); Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); @@ -545,7 +545,8 @@ public sealed class ProjectileControllerTests Assert.Same(runtime, record.ProjectileRuntime); Assert.Same(body, record.PhysicsBody); Assert.True(body.IsActive); // State preserves an already-active body. - Assert.Same(record.WorldEntity, fixture.Live.MaterializedWorldEntities[Guid]); + Assert.True(fixture.Live.TryGetWorldEntity(Guid, out WorldEntity entity)); + Assert.Same(record.WorldEntity, entity); Assert.Equal(1, fixture.Resources.Registers); } @@ -1501,12 +1502,8 @@ public sealed class ProjectileControllerTests angularVelocity ?? Vector3.Zero, friction, elasticity); - LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn); - Assert.NotNull(registration.Record); - registration.Record!.HasPartArray = true; - WorldEntity? entity = Live.MaterializeLiveEntity( - Guid, - cellId, + LiveEntityRecord record = Live.RegisterAndMaterializeProjection( + spawn, localId => new WorldEntity { Id = localId, @@ -1518,10 +1515,11 @@ public sealed class ProjectileControllerTests ? new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) } : Array.Empty(), ParentCellId = cellId, - }); - Assert.NotNull(entity); + }, + initializeProjection: exact => exact.HasPartArray = true); + WorldEntity entity = Assert.IsType(record.WorldEntity); Poses.PublishMeshRefs(entity); - return registration.Record!; + return record; } } diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 00fec7ae..735a95f2 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -697,7 +697,7 @@ public sealed class RemotePhysicsUpdaterTests new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); BindHiddenRemote(live, firstGuid); BindHiddenRemote(live, secondGuid); - Assert.Equal(2, live.SpatialRemoteMotionRuntimes.Count); + Assert.Equal(2, live.SpatialRemoteMotionRuntimeCount); var updater = new RemotePhysicsUpdater( new PhysicsEngine(), @@ -721,7 +721,7 @@ public sealed class RemotePhysicsUpdaterTests Assert.Single(published); Assert.Equal(published, partPoseDirty); - Assert.Single(live.SpatialRemoteMotionRuntimes); + Assert.Equal(1, live.SpatialRemoteMotionRuntimeCount); } private static PhysicsEngine BuildBoundaryEngine() @@ -908,14 +908,8 @@ public sealed class RemotePhysicsUpdaterTests Vector3 position, bool enqueueDestination) { - LiveEntityRegistrationResult registration = Live.RegisterLiveEntity( - Spawn(instanceSequence, position)); - LiveEntityRecord record = Assert.IsType( - registration.Record); - WorldEntity entity = Assert.IsType( - Live.MaterializeLiveEntity( - Guid, - SourceCell, + LiveEntityRecord record = Live.RegisterAndMaterializeProjection( + Spawn(instanceSequence, position), id => new WorldEntity { Id = id, @@ -925,7 +919,8 @@ public sealed class RemotePhysicsUpdaterTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = SourceCell, - })); + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.Position = position; remote.Body.Orientation = Quaternion.Identity; diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs index 9f20475c..0c1e0062 100644 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs @@ -1089,13 +1089,14 @@ public sealed class RemoteTeleportControllerTests const uint sourceCell = 0xA9B40039u; const uint destinationCell = 0xAAB40001u; RemoteTeleportController? controller = null; + LiveEntityRecord? owner = null; bool cleanupAfterFailureRan = false; var resources = new DelegateLiveEntityResourceLifecycle( _ => { }, _ => LiveEntityTeardown.Run( [ () => throw new InvalidOperationException("effect teardown failed"), - () => controller!.Forget(guid), + () => controller!.Forget(owner!), () => cleanupAfterFailureRan = true, ])); var spatial = new GpuWorldState(); @@ -1117,6 +1118,7 @@ public sealed class RemoteTeleportControllerTests var remote = new AcDream.App.Physics.RemoteMotion(); remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); live.SetRemoteMotionRuntime(guid, remote); + Assert.True(live.TryGetRecord(guid, out owner)); controller = new RemoteTeleportController( BuildEngine(includeDestination: false), live, diff --git a/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs index 9aa58ea3..29d718d1 100644 --- a/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/AttachmentUpdateOrderTests.cs @@ -20,7 +20,7 @@ public sealed class AttachmentUpdateOrderTests var calls = new List(); var children = insertionOrder.ToDictionary(id => id, id => parents[id]); - new AttachmentUpdateOrder().ForEachParentFirst( + new AttachmentUpdateOrder().ForEachParentFirst( children, static parentId => parentId, id => @@ -44,7 +44,7 @@ public sealed class AttachmentUpdateOrderTests }; var calls = new List(); - IReadOnlyList failed = new AttachmentUpdateOrder().ForEachParentFirst( + IReadOnlyList failed = new AttachmentUpdateOrder().ForEachParentFirst( children, static parentId => parentId, id => @@ -128,7 +128,7 @@ public sealed class AttachmentUpdateOrderTests [20u] = 10u, [30u] = 20u, }; - var order = new AttachmentUpdateOrder().CollectSubtreePostOrder( + var order = new AttachmentUpdateOrder().CollectSubtreePostOrder( children, 10u, static parentId => parentId); @@ -146,7 +146,7 @@ public sealed class AttachmentUpdateOrderTests }; var realized = new List(); - new AttachmentUpdateOrder().RealizeDescendants( + new AttachmentUpdateOrder().RealizeDescendants( 10u, parent => waitingByParent.TryGetValue(parent, out var waiting) ? waiting @@ -170,7 +170,7 @@ public sealed class AttachmentUpdateOrderTests }; var attempted = new List(); - new AttachmentUpdateOrder().RealizeDescendants( + new AttachmentUpdateOrder().RealizeDescendants( 10u, parent => waitingByParent.TryGetValue(parent, out var waiting) ? waiting diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index 65d31c10..2f550002 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -106,8 +106,8 @@ public sealed class EquippedChildProjectionWithdrawalTests LiveEntityProjectionKind.Attached); fixture.InstallAttached(parent, oldChild); - LiveEntityRecord replacement = fixture.Live.RegisterLiveEntity( - ControllerFixture.SpawnData(0x70000201u, generation: 2)).Record!; + LiveEntityRecord replacement = fixture.Live.RegisterAndMaterializeProjection( + ControllerFixture.SpawnData(0x70000201u, generation: 2)); Assert.Empty(fixture.Controller.AttachedEntityIds); Assert.True(fixture.Live.TryGetRecord(0x70000201u, out LiveEntityRecord current)); @@ -136,7 +136,7 @@ public sealed class EquippedChildProjectionWithdrawalTests fixture.Controller.ProjectionRemoved -= Throw; Assert.Equal(1, fixture.Live.RetryPendingTeardowns()); - static void Throw(uint _) => + static void Throw(LiveEntityRecord _) => throw new InvalidOperationException("injected projection observer failure"); } @@ -424,11 +424,11 @@ public sealed class EquippedChildProjectionWithdrawalTests fixture.Controller.OnParentEvent(new ParentEvent.Parsed( invalidParent.ServerGuid, childGuid, 1, 0, 1, 2)); - LiveEntityRecord child = fixture.RegisterOnly( + RuntimeEntityRecord childIdentity = fixture.RegisterOnly( childGuid, generation: 1, hasPosition: true); - fixture.Materialize(child); + LiveEntityRecord child = fixture.Materialize(childIdentity); Assert.True(fixture.Live.TryGetSnapshot( childGuid, out WorldSession.EntitySpawn childSpawn)); @@ -455,7 +455,7 @@ public sealed class EquippedChildProjectionWithdrawalTests ExactProjectionWithdrawalDisposition.Completed, Failure: null)); LiveEntityRecord parent = fixture.Spawn(0x70000254u, generation: 1); - LiveEntityRecord child = fixture.RegisterOnly( + RuntimeEntityRecord child = fixture.RegisterOnly( 0x70000255u, generation: 1, hasPosition: false); @@ -475,7 +475,7 @@ public sealed class EquippedChildProjectionWithdrawalTests out WorldSession.EntitySpawn snapshot)); Assert.Equal(parent.ServerGuid, snapshot.ParentGuid); Assert.Null(snapshot.Position); - Assert.Null(child.WorldEntity); + Assert.False(fixture.Live.TryGetRecord(child.ServerGuid, out _)); var relation = new ParentAttachmentRelation( parent.ServerGuid, child.ServerGuid, @@ -490,9 +490,14 @@ public sealed class EquippedChildProjectionWithdrawalTests Array.Empty()); fixture.Controller.OnPosePublished(parent.ServerGuid); - Assert.NotNull(child.WorldEntity); - Assert.True(child.IsSpatiallyProjected); - Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); + Assert.True(fixture.Live.TryGetRecord( + child.ServerGuid, + out LiveEntityRecord childProjection)); + Assert.NotNull(childProjection.WorldEntity); + Assert.True(childProjection.IsSpatiallyProjected); + Assert.Equal( + LiveEntityProjectionKind.Attached, + childProjection.ProjectionKind); } [Fact] @@ -513,13 +518,12 @@ public sealed class EquippedChildProjectionWithdrawalTests PlacementId = null, PositionSequence = 0, }; - LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!; - + fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); var expected = new ParentAttachmentRelation( parent.ServerGuid, - child.ServerGuid, + childSpawn.Guid, ParentLocation: 0, PlacementId: 0, ParentInstanceSequence: 0, @@ -527,6 +531,9 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.True(fixture.Live.ParentAttachments.IsCommitted(expected)); fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); fixture.Controller.OnPosePublished(parent.ServerGuid); + Assert.True(fixture.Live.TryGetRecord( + childSpawn.Guid, + out LiveEntityRecord child)); Assert.Equal(LiveEntityProjectionKind.Attached, child.ProjectionKind); Assert.NotNull(child.WorldEntity); } @@ -560,10 +567,13 @@ public sealed class EquippedChildProjectionWithdrawalTests PlacementId = 0, PositionSequence = 0, }; - LiveEntityRecord child = fixture.Live.RegisterLiveEntity(childSpawn).Record!; + fixture.Live.RegisterLiveEntity(childSpawn); fixture.Controller.OnSpawn(childSpawn); fixture.Poses.Publish(parent.WorldEntity!, Array.Empty()); fixture.Controller.OnPosePublished(parent.ServerGuid); + Assert.True(fixture.Live.TryGetRecord( + childSpawn.Guid, + out LiveEntityRecord child)); WorldEntity entity = child.WorldEntity!; Assert.Null(entity.PaletteOverride); WorldSession.EntitySpawn grandchildSpawn = ControllerFixture.SpawnData( @@ -576,9 +586,11 @@ public sealed class EquippedChildProjectionWithdrawalTests PlacementId = 0, PositionSequence = 0, }; - LiveEntityRecord grandchild = fixture.Live.RegisterLiveEntity( - grandchildSpawn).Record!; + fixture.Live.RegisterLiveEntity(grandchildSpawn); fixture.Controller.OnSpawn(grandchildSpawn); + Assert.True(fixture.Live.TryGetRecord( + grandchildSpawn.Guid, + out LiveEntityRecord grandchild)); WorldEntity grandchildEntity = grandchild.WorldEntity!; Assert.Equal( LiveEntityProjectionKind.Attached, @@ -642,12 +654,12 @@ public sealed class EquippedChildProjectionWithdrawalTests using (fixture) { LiveEntityRecord parent = fixture.Spawn(0x7000025Cu, generation: 1); - LiveEntityRecord child = fixture.RegisterOnly( + RuntimeEntityRecord childIdentity = fixture.RegisterOnly( 0x7000025Du, generation: 1, hasPosition: true, hasSetup: false); - fixture.Materialize(child); + LiveEntityRecord child = fixture.Materialize(childIdentity); child.HasPartArray = false; var update = new ParentEvent.Parsed( parent.ServerGuid, child.ServerGuid, 0, 0, 1, 1); @@ -763,7 +775,7 @@ public sealed class EquippedChildProjectionWithdrawalTests 0x70000257u, generation: 1, LiveEntityProjectionKind.Attached); - LiveEntityRecord waitingParent = fixture.RegisterOnly( + RuntimeEntityRecord waitingParent = fixture.RegisterOnly( 0x70000258u, generation: 1, hasPosition: true); @@ -1117,7 +1129,8 @@ public sealed class EquippedChildProjectionWithdrawalTests } } - private static LiveEntityRecord ChildRecord() => new( + private static LiveEntityRecord ChildRecord() => + LiveEntityTestFixture.CreateExactProjectionRecord( new WorldSession.EntitySpawn( 0x70000100u, new CreateObject.ServerPosition( @@ -1188,12 +1201,12 @@ public sealed class EquippedChildProjectionWithdrawalTests ushort generation, LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) { - LiveEntityRecord record = RegisterOnly(guid, generation, hasPosition: true); - Materialize(record, kind); - return record; + RuntimeEntityRecord canonical = + RegisterOnly(guid, generation, hasPosition: true); + return Materialize(canonical, kind); } - internal LiveEntityRecord RegisterOnly( + internal RuntimeEntityRecord RegisterOnly( uint guid, ushort generation, bool hasPosition, @@ -1204,28 +1217,35 @@ public sealed class EquippedChildProjectionWithdrawalTests spawn = spawn with { Position = null }; if (!hasSetup) spawn = spawn with { SetupTableId = null }; - return Live.RegisterLiveEntity(spawn).Record!; + return Assert.IsType( + Live.RegisterLiveEntity(spawn).Canonical); } - internal void Materialize( - LiveEntityRecord record, + internal LiveEntityRecord Materialize( + RuntimeEntityRecord canonical, LiveEntityProjectionKind kind = LiveEntityProjectionKind.World) { - Live.MaterializeLiveEntity( - record.ServerGuid, + WorldEntity? entity = Live.MaterializeLiveEntity( + canonical, Cell, id => new WorldEntity { Id = id, - ServerGuid = record.ServerGuid, + ServerGuid = canonical.ServerGuid, SourceGfxObjOrSetupId = 0x02000001u, Position = Vector3.Zero, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, }, - kind); + kind, + initializeProjection: null, + out LiveEntityRecord? projected); + Assert.NotNull(entity); + LiveEntityRecord record = + Assert.IsType(projected); record.HasPartArray = true; + return record; } internal static WorldSession.EntitySpawn SpawnData(uint guid, ushort generation) => new( @@ -1277,7 +1297,7 @@ public sealed class EquippedChildProjectionWithdrawalTests "_attachedByChild", BindingFlags.Instance | BindingFlags.NonPublic)!; var map = (IDictionary)mapField.GetValue(Controller)!; - map.Add(child.ServerGuid, attached); + map.Add(child.ProjectionKey!.Value, attached); } internal void CommitRenderedRelation(ParentAttachmentRelation relation) diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 0c690c23..b1938e82 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -20,7 +20,7 @@ public sealed class LiveAppearanceAnimationTests var runtime = new AcDream.App.World.LiveEntityRuntime( new AcDream.App.Streaming.GpuWorldState(), new AcDream.App.World.DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - var record = runtime.RegisterLiveEntity(new AcDream.Core.Net.WorldSession.EntitySpawn( + var spawn = new AcDream.Core.Net.WorldSession.EntitySpawn( guid, Position: null, SetupTableId: null, @@ -33,8 +33,11 @@ public sealed class LiveAppearanceAnimationTests ItemType: null, MotionState: null, MotionTableId: null, - InstanceSequence: 1)).Record!; - WorldEntity entity = Entity(0x70000002u, 0x01000001u); + InstanceSequence: 1); + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( + spawn, + id => Entity(id, 0x01000001u, guid)); + WorldEntity entity = Assert.IsType(record.WorldEntity); var state = new LiveEntityAnimationState { Entity = entity, @@ -47,7 +50,6 @@ public sealed class LiveAppearanceAnimationTests PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; - record.WorldEntity = entity; record.AnimationRuntime = state; LiveEntityAppearanceUpdateState captured = Assert.IsType( @@ -188,10 +190,13 @@ public sealed class LiveAppearanceAnimationTests MotionTableId: null, InstanceSequence: instance); - private static WorldEntity Entity(uint id, uint gfxObjId) => new() + private static WorldEntity Entity( + uint id, + uint gfxObjId, + uint serverGuid = 0x50000001u) => new() { Id = id, - ServerGuid = 0x50000001u, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x02000001u, Position = Vector3.Zero, Rotation = Quaternion.Identity, diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs index 380f5f13..97abac62 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -7,6 +7,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -64,9 +65,9 @@ public sealed class LiveEntityAnimationPresenterTests old.Live.SetAnimationRuntime(Guid, replacement); var presenter = Presenter(old.Live, new EntityEffectPoseRegistry(), new Context()); - presenter.Present(new Dictionary + presenter.Present(new Dictionary { - [old.Entity.Id] = stale, + [old.Record.ProjectionKey!.Value] = stale, }); Assert.Empty(replacement.MeshRefsScratch); @@ -178,9 +179,9 @@ public sealed class LiveEntityAnimationPresenterTests [true]); var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), new Context()); - presenter.Present(new Dictionary + presenter.Present(new Dictionary { - [fixture.Entity.Id] = stale, + [fixture.Record.ProjectionKey!.Value] = stale, }); Assert.Empty(fixture.State.MeshRefsScratch); @@ -252,11 +253,11 @@ public sealed class LiveEntityAnimationPresenterTests Assert.Equal(2, nested.Count); }; var presenter = Presenter(first.Live, poses, new Context()); - var schedules = new Dictionary + var schedules = new Dictionary { - [first.Entity.Id] = Schedule(first, + [first.Record.ProjectionKey!.Value] = Schedule(first, [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), - [second.Entity.Id] = Schedule(second, + [second.Record.ProjectionKey!.Value] = Schedule(second, [new PartTransform(Vector3.UnitY, Quaternion.Identity)]), }; @@ -283,11 +284,11 @@ public sealed class LiveEntityAnimationPresenterTests first.Live.SetAnimationRuntime(Guid + 1, replacement); }; var presenter = Presenter(first.Live, poses, new Context()); - var schedules = new Dictionary + var schedules = new Dictionary { - [first.Entity.Id] = Schedule(first, + [first.Record.ProjectionKey!.Value] = Schedule(first, [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), - [second.Entity.Id] = Schedule(second, + [second.Record.ProjectionKey!.Value] = Schedule(second, [new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]), }; @@ -305,11 +306,11 @@ public sealed class LiveEntityAnimationPresenterTests var second = Add(first.Live, Guid + 1, partCount: 1); var poses = new EntityEffectPoseRegistry(); var presenter = Presenter(first.Live, poses, new Context()); - var schedules = new Dictionary + var schedules = new Dictionary { - [first.Entity.Id] = Schedule(first, + [first.Record.ProjectionKey!.Value] = Schedule(first, [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), - [second.Entity.Id] = Schedule(second, + [second.Record.ProjectionKey!.Value] = Schedule(second, [new PartTransform(Vector3.UnitY, Quaternion.Identity)]), }; bool recursed = false; @@ -359,9 +360,9 @@ public sealed class LiveEntityAnimationPresenterTests fixture.Record.ObjectClockEpoch, fixture.Record.ProjectionMutationVersion, fixture.State.PresentationRevision); - var schedules = new Dictionary + var schedules = new Dictionary { - [fixture.Entity.Id] = schedule, + [fixture.Record.ProjectionKey!.Value] = schedule, }; var poses = new EntityEffectPoseRegistry(); var presenter = Presenter(fixture.Live, poses, new Context()); @@ -407,12 +408,12 @@ public sealed class LiveEntityAnimationPresenterTests } } - private static IReadOnlyDictionary Schedules( + private static IReadOnlyDictionary Schedules( Fixture fixture, IReadOnlyList frames) => - new Dictionary + new Dictionary { - [fixture.Entity.Id] = Schedule(fixture, frames), + [fixture.Record.ProjectionKey!.Value] = Schedule(fixture, frames), }; private static LiveEntityAnimationSchedule Schedule( @@ -452,10 +453,8 @@ public sealed class LiveEntityAnimationPresenterTests float scale = 1f, bool withSequencer = true) { - LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!; - WorldEntity entity = live.MaterializeLiveEntity( - guid, - Cell, + LiveEntityRecord record = live.RegisterAndMaterializeProjection( + Spawn(guid), id => new WorldEntity { Id = id, @@ -465,7 +464,8 @@ public sealed class LiveEntityAnimationPresenterTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); LiveEntityAnimationState state = State(entity, partCount, scale, withSequencer); entity.SetIndexedPartPoses( Enumerable.Repeat(Matrix4x4.Identity, partCount).ToArray(), diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index ca8d646b..ad94b17f 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -10,6 +10,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.World; using AcDream.Core.Vfx; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -36,14 +37,14 @@ public sealed class LiveEntityAnimationSchedulerTests localHiddenPartPoseDirty: true, liveCenterX: 0, liveCenterY: 0) - .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked)); + .TryGetValue(record.ProjectionKey!.Value, out LiveEntityAnimationSchedule marked)); bool repeated = scheduler.Tick( PhysicsBody.MaxQuantum, animation.Entity.Position, localHiddenPartPoseDirty: false, liveCenterX: 0, liveCenterY: 0) - .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed); + .TryGetValue(record.ProjectionKey!.Value, out LiveEntityAnimationSchedule consumed); Assert.True(marked.ComposeParts); Assert.NotNull(marked.SequenceFrames); @@ -73,14 +74,14 @@ public sealed class LiveEntityAnimationSchedulerTests localHiddenPartPoseDirty: false, liveCenterX: 0, liveCenterY: 0) - .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule marked)); + .TryGetValue(record.ProjectionKey!.Value, out LiveEntityAnimationSchedule marked)); bool repeated = scheduler.Tick( elapsedSeconds: 0f, playerPosition: null, localHiddenPartPoseDirty: false, liveCenterX: 0, liveCenterY: 0) - .TryGetValue(animation.Entity.Id, out LiveEntityAnimationSchedule consumed); + .TryGetValue(record.ProjectionKey!.Value, out LiveEntityAnimationSchedule consumed); Assert.True(marked.ComposeParts); Assert.NotNull(marked.SequenceFrames); @@ -91,7 +92,7 @@ public sealed class LiveEntityAnimationSchedulerTests [Fact] public void VisibleAnimationWithoutRemoteMotion_AppliesCompleteRootFrame() { - var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); var retainedBodyOwner = BuildRemote(animation.Entity); retainedBodyOwner.Body.SnapToCell( Cell, @@ -108,7 +109,7 @@ public sealed class LiveEntityAnimationSchedulerTests localHiddenPartPoseDirty: false, liveCenterX: 0, liveCenterY: 0) - .ContainsKey(animation.Entity.Id)); + .ContainsKey(record.ProjectionKey!.Value)); Assert.True(animation.Entity.Position.X > startX + 1f); } @@ -116,14 +117,14 @@ public sealed class LiveEntityAnimationSchedulerTests [Fact] public void ScheduleOwnsPartFramesAcrossLaterSequencerSampling() { - var (live, _, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); + var (live, record, animation) = BuildVisibleAnimatedWithPosFrames(RemoteGuid); LiveEntityAnimationScheduler scheduler = BuildScheduler(live, LocalGuid); LiveEntityAnimationSchedule schedule = scheduler.Tick( 0.1f, animation.Entity.Position, localHiddenPartPoseDirty: false, liveCenterX: 0, - liveCenterY: 0)[animation.Entity.Id]; + liveCenterY: 0)[record.ProjectionKey!.Value]; PartTransform retained = schedule.SequenceFrames![0]; animation.CaptureSequenceFrames( [new PartTransform(new Vector3(77f, 76f, 75f), Quaternion.Identity)]); @@ -194,7 +195,7 @@ public sealed class LiveEntityAnimationSchedulerTests liveCenterY: 0); Assert.True(entity.Position.X > startX); - Assert.Same(record, live.SpatialRootObjects[RemoteGuid]); + Assert.True(live.IsCurrentSpatialRootObject(record)); } [Fact] @@ -224,7 +225,7 @@ public sealed class LiveEntityAnimationSchedulerTests Assert.True(entity.Position.X > startX + 0.5f); Assert.True(remote.Interp.IsActive || entity.Position.X >= startX + 0.99f); - Assert.Same(record, live.SpatialRootObjects[RemoteGuid]); + Assert.True(live.IsCurrentSpatialRootObject(record)); } [Fact] @@ -343,14 +344,14 @@ public sealed class LiveEntityAnimationSchedulerTests physics: physics, projectiles: projectiles); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( 0.1f, animation.Entity.Position, localHiddenPartPoseDirty: false, liveCenterX: 1, liveCenterY: 1); - Assert.Contains(animation.Entity.Id, schedules.Keys); + Assert.Contains(record.ProjectionKey!.Value, schedules.Keys); Assert.Equal(1, rootPublishes); float distance = animation.Entity.Position.X - startX; Assert.InRange(distance, 0.79f, 0.81f); @@ -374,7 +375,7 @@ public sealed class LiveEntityAnimationSchedulerTests isLocalPlayer: false); }); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( PhysicsBody.MaxQuantum * 2.5f, animation.Entity.Position, localHiddenPartPoseDirty: false, @@ -407,7 +408,7 @@ public sealed class LiveEntityAnimationSchedulerTests }, _ => rootPublishes++); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( PhysicsBody.MaxQuantum * 2.5f, animation.Entity.Position, localHiddenPartPoseDirty: false, @@ -441,7 +442,7 @@ public sealed class LiveEntityAnimationSchedulerTests }, _ => rootPublishes++); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( PhysicsBody.MaxQuantum * 2.5f, animation.Entity.Position, localHiddenPartPoseDirty: false, @@ -472,7 +473,7 @@ public sealed class LiveEntityAnimationSchedulerTests }, _ => rootPublishes++); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( PhysicsBody.MaxQuantum * 2.5f, animation.Entity.Position, localHiddenPartPoseDirty: false, @@ -509,7 +510,7 @@ public sealed class LiveEntityAnimationSchedulerTests }, _ => rootPublishes++); - IReadOnlyDictionary schedules = scheduler.Tick( + IReadOnlyDictionary schedules = scheduler.Tick( PhysicsBody.MaxQuantum * 2.5f, animation.Entity.Position, localHiddenPartPoseDirty: false, @@ -572,7 +573,7 @@ public sealed class LiveEntityAnimationSchedulerTests _ => throw new InvalidOperationException(), LiveEntityProjectionKind.Attached)); Assert.False(record.ObjectClock.IsActive); - Assert.DoesNotContain(RemoteGuid, live.SpatialRootObjects.Keys); + Assert.False(live.IsCurrentSpatialRootObject(record)); scheduler.Tick( PhysicsBody.MaxQuantum, @@ -590,7 +591,7 @@ public sealed class LiveEntityAnimationSchedulerTests _ => throw new InvalidOperationException(), LiveEntityProjectionKind.World)); Assert.True(record.ObjectClock.IsActive); - Assert.Contains(RemoteGuid, live.SpatialRootObjects.Keys); + Assert.True(live.IsCurrentSpatialRootObject(record)); scheduler.Tick( PhysicsBody.MaxQuantum, @@ -634,8 +635,8 @@ public sealed class LiveEntityAnimationSchedulerTests (_, _) => (0.48f, 1.835f)); var identity = new LocalPlayerIdentityState { ServerGuid = localPlayerGuid }; var poses = new EntityEffectPoseRegistry(); - foreach (WorldEntity entity in live.MaterializedWorldEntities.Values) - poses.PublishMeshRefs(entity); + foreach (LiveEntityRecord record in live.MaterializedRecords) + poses.PublishMeshRefs(record.WorldEntity!); IAnimationHookCaptureSink animationHooks = captureHooks is null ? new AnimationHookCaptureSink(new AnimationHookFrameQueue( new AnimationHookRouter(), @@ -681,10 +682,8 @@ public sealed class LiveEntityAnimationSchedulerTests var live = new LiveEntityRuntime( spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!; - WorldEntity entity = live.MaterializeLiveEntity( - guid, - Cell, + LiveEntityRecord record = live.RegisterAndMaterializeProjection( + Spawn(guid), id => new WorldEntity { Id = id, @@ -694,7 +693,8 @@ public sealed class LiveEntityAnimationSchedulerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); var setup = new Setup(); var animation = new LiveEntityAnimationState { @@ -803,10 +803,8 @@ public sealed class LiveEntityAnimationSchedulerTests var live = new LiveEntityRuntime( spatial, new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid, state)).Record!; - WorldEntity entity = live.MaterializeLiveEntity( - guid, - Cell, + LiveEntityRecord record = live.RegisterAndMaterializeProjection( + Spawn(guid, state), id => new WorldEntity { Id = id, @@ -816,7 +814,8 @@ public sealed class LiveEntityAnimationSchedulerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); return (live, record, entity); } diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs index 6ceb0d5c..3c3bbf7f 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityCreateSupersessionRecoveryTests.cs @@ -294,10 +294,8 @@ public sealed class LiveEntityCreateSupersessionRecoveryTests private static LiveEntityRecord RegisterAndMaterialize(LiveEntityRuntime runtime) { - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn()).Record!; - runtime.MaterializeLiveEntity( - Guid, - Cell, + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection( + Spawn(), id => new WorldEntity { Id = id, diff --git a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs index c87a37a7..260381de 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveRenderProjectionJournalTests.cs @@ -147,8 +147,8 @@ public sealed class LiveRenderProjectionJournalTests incarnation, harness.Journal.Pending[0].Record.OwnerIncarnation); Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); - harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); - harness.Projections.OnProjectionRemoved(record.WorldEntity.Id); + harness.Projections.OnProjectionRemoved(record); + harness.Projections.OnProjectionRemoved(record); Assert.Equal(2, harness.Journal.Count); Assert.Equal( RenderProjectionDeltaKind.UpdateFlags, @@ -166,13 +166,10 @@ public sealed class LiveRenderProjectionJournalTests uint firstLocalId = first.WorldEntity!.Id; harness.Journal.DrainTo(harness.Scene); - LiveEntityRegistrationResult replacement = - harness.Runtime.RegisterLiveEntity(Spawn(Guid, instance: 5)); - LiveEntityRecord second = replacement.Record!; - WorldEntity secondEntity = harness.Runtime.MaterializeLiveEntity( - Guid, - CellId, - localId => Entity(localId, Guid))!; + LiveEntityRecord second = harness.Runtime.RegisterAndMaterializeProjection( + Spawn(Guid, instance: 5), + localId => Entity(localId, Guid)); + WorldEntity secondEntity = Assert.IsType(second.WorldEntity); second.InitialHydrationCompleted = true; LiveEntityReadyCandidate current = LiveEntityReadyCandidate.Capture(second); Assert.True(harness.Projections.OnEntityReady(current)); @@ -209,7 +206,7 @@ public sealed class LiveRenderProjectionJournalTests LiveEntityRegistrationResult duplicate = harness.Runtime.RegisterLiveEntity(Spawn(Guid, instance: 5)); - Assert.Same(record, duplicate.Record); + Assert.Same(record, duplicate.Projection); Assert.True(harness.Projections.OnEntityReady( LiveEntityReadyCandidate.Capture(record))); @@ -285,7 +282,7 @@ public sealed class LiveRenderProjectionJournalTests LiveEntityReadyCandidate.Capture(record))); harness.Journal.DrainTo(harness.Scene); Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); - harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); + harness.Projections.OnProjectionRemoved(record); harness.Journal.DrainTo(harness.Scene); harness.Projections.SynchronizeActiveSources(); @@ -311,7 +308,7 @@ public sealed class LiveRenderProjectionJournalTests LiveEntityReadyCandidate.Capture(record))); harness.Journal.DrainTo(harness.Scene); Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); - harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); + harness.Projections.OnProjectionRemoved(record); harness.Journal.DrainTo(harness.Scene); Assert.True(harness.Runtime.RebucketLiveEntity(Guid, CellId)); @@ -337,7 +334,7 @@ public sealed class LiveRenderProjectionJournalTests LiveEntityReadyCandidate.Capture(record))); harness.Journal.DrainTo(harness.Scene); Assert.True(harness.Runtime.WithdrawLiveEntityProjection(Guid)); - harness.Projections.OnProjectionRemoved(record.WorldEntity!.Id); + harness.Projections.OnProjectionRemoved(record); harness.Journal.DrainTo(harness.Scene); Assert.True(harness.Runtime.UnregisterLiveEntity( @@ -383,14 +380,11 @@ public sealed class LiveRenderProjectionJournalTests LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World) { - LiveEntityRecord record = - harness.Runtime.RegisterLiveEntity(Spawn(guid, instance)).Record!; - WorldEntity? entity = harness.Runtime.MaterializeLiveEntity( - guid, - CellId, + LiveEntityRecord record = harness.Runtime.RegisterAndMaterializeProjection( + Spawn(guid, instance), localId => Entity(localId, guid), projectionKind); - Assert.NotNull(entity); + Assert.NotNull(record.WorldEntity); record.InitialHydrationCompleted = true; return record; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index df37c5e7..0ed92521 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -635,11 +635,10 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests body.SnapToCell(0x01010001u, entity.Position, Vector3.Zero); LiveEntityAnimationState state = LiveState(entity, setup, sequencer); Assert.True(scheduler.BindLiveOwner(entity, state, body)); - var record = new LiveEntityRecord(Spawn(serverGuid)) - { - WorldEntity = entity, - AnimationRuntime = state, - }; + var record = LiveEntityTestFixture.CreateExactProjectionRecord( + Spawn(serverGuid)); + record.WorldEntity = entity; + record.AnimationRuntime = state; int motionDone = 0; sequencer.MotionDoneTarget = (_, success) => { diff --git a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs index c206db91..f9ce6571 100644 --- a/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Vfx/EntityEffectControllerTests.cs @@ -119,12 +119,12 @@ public sealed class EntityEffectControllerTests EntityEffectProfile? profile = null) { WorldSession.EntitySpawn spawn = Spawn(guid, generation); - Runtime.RegisterLiveEntity(spawn); - Runtime.SetEffectProfile(guid, profile ?? LiveProfile()); - WorldEntity entity = Runtime.MaterializeLiveEntity( - guid, - spawn.Position!.Value.LandblockId, - id => Entity(id, guid))!; + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection( + spawn, + id => Entity(id, guid), + initializeProjection: exact => + exact.EffectProfile = profile ?? LiveProfile()); + WorldEntity entity = Assert.IsType(record.WorldEntity); Assert.True(Controller.OnLiveEntityReady(guid)); return entity; } @@ -172,12 +172,11 @@ public sealed class EntityEffectControllerTests var fixture = new Fixture(); fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); WorldSession.EntitySpawn spawn = Spawn(Guid, 1); - fixture.Runtime.RegisterLiveEntity(spawn); - fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile()); - fixture.Runtime.MaterializeLiveEntity( - Guid, - spawn.Position!.Value.LandblockId, - id => Entity(id, Guid)); + fixture.Runtime.RegisterAndMaterializeProjection( + spawn, + id => Entity(id, Guid), + initializeProjection: record => + record.EffectProfile = Fixture.LiveProfile()); Assert.True(fixture.Controller.PrepareLiveEntityOwner(Guid)); Assert.Equal(1, fixture.Controller.PendingPacketCount); @@ -279,12 +278,13 @@ public sealed class EntityEffectControllerTests var fixture = new Fixture(); fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid)); WorldSession.EntitySpawn spawn = Spawn(Guid, 1); - fixture.Runtime.RegisterLiveEntity(spawn); - fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile()); - WorldEntity entity = fixture.Runtime.MaterializeLiveEntity( - Guid, - 0x02020001u, - id => Entity(id, Guid))!; + LiveEntityRecord record = fixture.Runtime.RegisterAndMaterializeProjection( + spawn, + id => Entity(id, Guid), + initializeProjection: exact => + exact.EffectProfile = Fixture.LiveProfile()); + WorldEntity entity = Assert.IsType(record.WorldEntity); + fixture.Runtime.RebucketLiveEntity(Guid, 0x02020001u); Assert.True(fixture.Controller.OnLiveEntityReady(Guid)); Assert.Equal(1, fixture.Controller.PendingPacketCount); @@ -360,6 +360,7 @@ public sealed class EntityEffectControllerTests WorldSession.EntitySpawn first = Spawn(Guid, generation: 1); fixture.Runtime.RegisterLiveEntity(first); + fixture.Controller.ForgetUnknownOwner(Guid); Assert.True(fixture.Runtime.UnregisterLiveEntity( new DeleteObject.Parsed(Guid, 1), isLocalPlayer: false)); @@ -383,11 +384,11 @@ public sealed class EntityEffectControllerTests isLocalPlayer: false)); Assert.Equal(1, fixture.Controller.PendingPacketCount); - fixture.Runtime.SetEffectProfile(Guid, Fixture.LiveProfile()); - fixture.Runtime.MaterializeLiveEntity( - Guid, - current.Position!.Value.LandblockId, - id => Entity(id, Guid)); + fixture.Runtime.RegisterAndMaterializeProjection( + current, + id => Entity(id, Guid), + initializeProjection: record => + record.EffectProfile = Fixture.LiveProfile()); Assert.True(fixture.Controller.OnLiveEntityReady(Guid)); fixture.Runner.Tick(0.0); Assert.Equal([1u], EmitterIds(fixture.Sink)); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs index 61d258fc..35e66db4 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EntitySpawnAdapterLifetimeTests.cs @@ -150,7 +150,7 @@ public sealed class EntitySpawnAdapterLifetimeTests var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); WorldEntity oldEntity = MakeEntity(61u, guid); oldEntity.MeshRefs = [new MeshRef(0x01000100u, Matrix4x4.Identity)]; - WorldEntity replacement = MakeEntity(62u, guid); + WorldEntity replacement = MakeEntity(61u, guid); replacement.MeshRefs = [new MeshRef(0x01000200u, Matrix4x4.Identity)]; adapter.OnCreate(oldEntity); @@ -280,7 +280,7 @@ public sealed class EntitySpawnAdapterLifetimeTests var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes); WorldEntity oldEntity = MakeEntity(101u, guid); oldEntity.MeshRefs = [new MeshRef((uint)oldMeshId, Matrix4x4.Identity)]; - WorldEntity replacement = MakeEntity(102u, guid); + WorldEntity replacement = MakeEntity(101u, guid); AnimatedEntityState oldState = Assert.IsType(adapter.OnCreate(oldEntity)); Assert.True(adapter.SetPresentationResident(oldEntity, resident: true)); @@ -682,7 +682,7 @@ public sealed class EntitySpawnAdapterLifetimeTests adapter.OnCreate(oldEntity); Assert.True(adapter.SetPresentationResident(oldEntity, resident: true)); - WorldEntity replacement = MakeEntity(202u, guid); + WorldEntity replacement = MakeEntity(201u, guid); replacement.MeshRefs = [new MeshRef((uint)replacementMesh, Matrix4x4.Identity)]; adapter.OnCreate(replacement); Assert.True(adapter.SetPresentationResident(replacement, resident: true)); diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 68a91bb3..7f68d24c 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -40,12 +40,12 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.Equal(Harness.PlayerGuid, harness.Identity.ServerGuid); Assert.Equal(RuntimeLifecycleState.InWorld, harness.Runtime.Lifecycle.State); - LiveEntityRegistrationResult registration = - harness.Entities.RegisterLiveEntity(Spawn( + LiveEntityRecord liveRecord = + harness.Entities.RegisterAndMaterializeProjection(Spawn( Harness.TargetGuid, instance: 3, cell: 0x12340001u)); - Assert.NotNull(registration.Record); + Assert.NotNull(liveRecord.WorldEntity); var item = new ClientObject { diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index abb30753..9e882ff1 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -227,14 +227,15 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73600001u); state.PlaceLiveEntityProjection(landblock, entity); - var edges = new List<(uint Guid, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible)); + var edges = new List<(uint LocalEntityId, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (localEntityId, visible) => + edges.Add((localEntityId, visible)); state.RemoveLandblock(landblock); Assert.Empty(state.Entities); Assert.Equal(1, state.PendingLiveEntityCount); - Assert.False(state.IsLiveEntityVisible(entity.ServerGuid)); + Assert.False(state.IsLiveEntityVisible(entity.Id)); state.AddLandblock(new LoadedLandblock( landblock, @@ -244,9 +245,9 @@ public sealed class GpuWorldStateVisibilityTests Assert.Same(entity, Assert.Single(state.Entities)); Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded)); Assert.Same(entity, Assert.Single(reloaded!.Entities)); - Assert.True(state.IsLiveEntityVisible(entity.ServerGuid)); + Assert.True(state.IsLiveEntityVisible(entity.Id)); Assert.Equal( - [(entity.ServerGuid, false), (entity.ServerGuid, true)], + [(entity.Id, false), (entity.Id, true)], edges); state.RemoveLiveEntityProjection(entity); @@ -279,9 +280,9 @@ public sealed class GpuWorldStateVisibilityTests state.MarkPersistent(playerGuid); state.PlaceLiveEntityProjection(secondLandblock, player); state.PlaceLiveEntityProjection(pendingLandblock, pending); - var edges = new List<(uint Guid, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (guid, visible) => - edges.Add((guid, visible)); + var edges = new List<(uint LocalEntityId, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (localEntityId, visible) => + edges.Add((localEntityId, visible)); GpuWorldRecenterRetirement result = state.DetachAllForOriginRecenter(); @@ -316,7 +317,7 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(2, state.PendingLiveEntityCount); Assert.Same(player, Assert.Single(state.DrainRescued())); Assert.Equal( - [(remote.ServerGuid, false), (player.ServerGuid, false)], + [(remote.Id, false), (player.Id, false)], edges); state.AddLandblock(new LoadedLandblock( @@ -378,12 +379,12 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73700001u); int callbacks = 0; - state.LiveProjectionVisibilityChanged += (guid, visible) => + state.LiveProjectionVisibilityChanged += (localEntityId, visible) => { callbacks++; - Assert.Equal(entity.ServerGuid, guid); + Assert.Equal(entity.Id, localEntityId); Assert.True(visible); - Assert.True(state.IsLiveEntityVisible(guid)); + Assert.True(state.IsLiveEntityVisible(localEntityId)); Assert.Same(entity, Assert.Single(state.Entities)); Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); Assert.Same(entity, Assert.Single(loaded!.Entities)); @@ -414,13 +415,14 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73800001u); state.PlaceLiveEntityProjection(sourceLandblock, entity); - var edges = new List<(uint Guid, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible)); + var edges = new List<(uint LocalEntityId, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (localEntityId, visible) => + edges.Add((localEntityId, visible)); state.RebucketLiveEntity(entity, targetLandblock); Assert.Empty(edges); - Assert.True(state.IsLiveEntityVisible(entity.ServerGuid)); + Assert.True(state.IsLiveEntityVisible(entity.Id)); Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); Assert.Empty(source!.Entities); Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); @@ -429,7 +431,7 @@ public sealed class GpuWorldStateVisibilityTests } [Fact] - public void SameGuidOverlap_ExactRemovalPromotesSecondaryWithoutVisibilityPulse() + public void SameGuidOverlap_TracksExactProjectionVisibilityIndependently() { const uint landblock = 0x0101FFFFu; const uint guid = 0x73900001u; @@ -442,20 +444,21 @@ public sealed class GpuWorldStateVisibilityTests WorldEntity second = Entity(2u, guid); state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, second); - var edges = new List<(uint Guid, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (edgeGuid, visible) => - edges.Add((edgeGuid, visible)); + var edges = new List<(uint LocalEntityId, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (localEntityId, visible) => + edges.Add((localEntityId, visible)); state.RemoveLiveEntityProjection(first); - Assert.Empty(edges); - Assert.True(state.IsLiveEntityVisible(guid)); + Assert.Equal([(first.Id, false)], edges); + Assert.False(state.IsLiveEntityVisible(first.Id)); + Assert.True(state.IsLiveEntityVisible(second.Id)); Assert.Same(second, Assert.Single(state.Entities)); state.RemoveLiveEntityProjection(guid); - Assert.Equal([(guid, false)], edges); - Assert.False(state.IsLiveEntityVisible(guid)); + Assert.Equal([(first.Id, false), (second.Id, false)], edges); + Assert.False(state.IsLiveEntityVisible(second.Id)); Assert.Empty(state.Entities); } @@ -488,7 +491,7 @@ public sealed class GpuWorldStateVisibilityTests var state = new GpuWorldState(); state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, second); - var observed = new List<(uint Guid, bool Visible)>(); + var observed = new List<(uint LocalEntityId, bool Visible)>(); state.LiveProjectionVisibilityChanged += (_, _) => throw new InvalidOperationException("fixture observer failure"); state.LiveProjectionVisibilityChanged += (guid, visible) => @@ -502,10 +505,10 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(2, error.InnerExceptions.Count); Assert.Equal( - [(first.ServerGuid, true), (second.ServerGuid, true)], - observed.OrderBy(edge => edge.Guid).ToArray()); - Assert.True(state.IsLiveEntityVisible(first.ServerGuid)); - Assert.True(state.IsLiveEntityVisible(second.ServerGuid)); + [(first.Id, true), (second.Id, true)], + observed.OrderBy(edge => edge.LocalEntityId).ToArray()); + Assert.True(state.IsLiveEntityVisible(first.Id)); + Assert.True(state.IsLiveEntityVisible(second.Id)); Assert.Equal(0, state.PendingVisibilityTransitionCount); Assert.Equal(2, state.Entities.Count); } @@ -574,14 +577,14 @@ public sealed class GpuWorldStateVisibilityTests cell, id => Entity(id, guid)); - Assert.False(state.IsLiveEntityVisible(guid)); - Assert.True(state.IsLiveEntityProjectionResident(guid)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); + Assert.False(state.IsLiveEntityVisible(record.WorldEntity!.Id)); + Assert.True(state.IsLiveEntityProjectionResident(record.WorldEntity.Id)); Assert.True(record.IsSpatiallyVisible); Assert.Equal([true], edges); Assert.True(availability.End(7)); - Assert.True(state.IsLiveEntityVisible(guid)); + Assert.True(state.IsLiveEntityVisible(record.WorldEntity.Id)); Assert.True(record.IsSpatiallyVisible); } diff --git a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs index 7af26848..8bb9e57f 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs @@ -26,7 +26,7 @@ public sealed class WorldGenerationQuiescenceTests world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One); var nearby = new List>(); - Assert.True(world.IsLiveEntityVisible(ServerGuid)); + Assert.True(world.IsLiveEntityVisible(entity.Id)); Assert.Single(world.LandblockEntries); Assert.Single(world.LandblockBounds); @@ -34,7 +34,7 @@ public sealed class WorldGenerationQuiescenceTests world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby); Assert.False(availability.IsWorldAvailable); - Assert.False(world.IsLiveEntityVisible(ServerGuid)); + Assert.False(world.IsLiveEntityVisible(entity.Id)); Assert.Empty(world.LandblockEntries); Assert.Empty(world.LandblockBounds); Assert.Empty(nearby); @@ -44,7 +44,7 @@ public sealed class WorldGenerationQuiescenceTests Assert.False(availability.End(16)); Assert.False(availability.IsWorldAvailable); Assert.True(availability.End(17)); - Assert.True(world.IsLiveEntityVisible(ServerGuid)); + Assert.True(world.IsLiveEntityVisible(entity.Id)); Assert.Same(entity, Assert.Single(world.LandblockEntries).Entities.Single()); } @@ -57,7 +57,8 @@ public sealed class WorldGenerationQuiescenceTests LandblockId, new LandBlock(), Array.Empty())); - world.PlaceLiveEntityProjection(LandblockId, Entity()); + WorldEntity entity = Entity(); + world.PlaceLiveEntityProjection(LandblockId, entity); var selection = new SelectionState(); selection.Select(ServerGuid, SelectionChangeSource.World); var audio = new RecordingAudioQuiescence(); @@ -65,6 +66,7 @@ public sealed class WorldGenerationQuiescenceTests availability, selection, world, + guid => guid == ServerGuid ? entity.Id : null, audio); quiescence.Begin(1); @@ -95,6 +97,7 @@ public sealed class WorldGenerationQuiescenceTests availability, selection, world, + _ => null, audio: null); quiescence.Begin(3); diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index c8a20886..95151ad9 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -132,6 +132,7 @@ public sealed class WorldRevealCoordinatorTests availability, new SelectionState(), world, + _ => null, audio); var coordinator = new WorldRevealCoordinator( isRenderNeighborhoodReady: (_, _) => true, @@ -165,6 +166,7 @@ public sealed class WorldRevealCoordinatorTests availability, new SelectionState(), world, + _ => null, audio); var scheduler = new RecordingDestinationScheduler(); var coordinator = new WorldRevealCoordinator( @@ -215,6 +217,7 @@ public sealed class WorldRevealCoordinatorTests availability, new SelectionState(), world, + _ => null, audio: null); var scheduler = new RecordingDestinationScheduler(); var coordinator = new WorldRevealCoordinator( diff --git a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs index 393c682a..694c3d96 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RadarSnapshotProviderTests.cs @@ -37,7 +37,7 @@ public sealed class RadarSnapshotProviderTests uint? selected = monster; var provider = new RadarSnapshotProvider( - objects, () => entities, () => spawns, + objects, new RadarEntities(() => entities), () => spawns, playerGuid: () => player, playerYawRadians: () => MathF.PI / 2f, // retail heading 0 degrees playerCellId: () => 0xA9B40001u, @@ -83,7 +83,7 @@ public sealed class RadarSnapshotProviderTests [hidden] = Spawn(hidden), }; var provider = new RadarSnapshotProvider( - objects, () => entities, () => spawns, + objects, new RadarEntities(() => entities), () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, playerCellId: () => 0xA9B40100u, // EnvCell: no landscape coordinate @@ -122,15 +122,16 @@ public sealed class RadarSnapshotProviderTests }; var provider = new RadarSnapshotProvider( objects, - () => interactionVisible, + new RadarEntities( + () => interactionVisible, + () => canonical), () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, playerCellId: () => 0xA9B40001u, selectedGuid: () => hiddenTarget, coordinatesOnRadar: () => true, - uiLocked: () => false, - playerEntities: () => canonical); + uiLocked: () => false); var snapshot = provider.BuildSnapshot(); @@ -158,15 +159,14 @@ public sealed class RadarSnapshotProviderTests new Dictionary(); var provider = new RadarSnapshotProvider( objects, - () => visible, + new RadarEntities(() => visible, () => canonical), () => spawns, playerGuid: () => player, playerYawRadians: () => MathF.PI / 2f, playerCellId: () => 0xA9B40001u, selectedGuid: () => null, coordinatesOnRadar: () => true, - uiLocked: () => false, - playerEntities: () => canonical); + uiLocked: () => false); Assert.Null(provider.BuildSnapshot().CoordinatesText); @@ -215,7 +215,7 @@ public sealed class RadarSnapshotProviderTests new KeyValuePair(nearby, entities[nearby])); var provider = new RadarSnapshotProvider( objects, - () => entities, + new RadarEntities(() => entities), () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, @@ -252,7 +252,7 @@ public sealed class RadarSnapshotProviderTests ILiveEntitySpatialQuery currentSpatialQuery = new RecordingSpatialQuery(); var provider = new RadarSnapshotProvider( objects, - () => entities, + new RadarEntities(() => entities), () => spawns, playerGuid: () => player, playerYawRadians: () => 0f, @@ -287,6 +287,35 @@ public sealed class RadarSnapshotProviderTests } } + private sealed class RadarEntities( + Func> visible, + Func>? materialized = null) + : ILiveEntityRadarSource + { + private readonly Func> _visible = + visible; + private readonly Func> _materialized = + materialized ?? visible; + + public bool TryGetMaterialized( + uint serverGuid, + out WorldEntity entity) => + _materialized().TryGetValue(serverGuid, out entity!); + + public bool TryGetVisible( + uint serverGuid, + out WorldEntity entity) => + _visible().TryGetValue(serverGuid, out entity!); + + public void CopyVisibleTo( + List> destination) + { + destination.Clear(); + foreach (KeyValuePair pair in _visible()) + destination.Add(pair); + } + } + private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new() { Id = guid, diff --git a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs index 21f6f1a2..43dff17c 100644 --- a/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs +++ b/tests/AcDream.App.Tests/World/DeferredLiveEntityRuntimeComponentLifecycleTests.cs @@ -115,7 +115,7 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }), bridge); WorldSession.EntitySpawn spawn = CreateSpawn(guid); - LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(spawn); var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1); Assert.Throws(() => @@ -139,7 +139,8 @@ public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests var runtime = new LiveEntityRuntime( new GpuWorldState(), new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); - return runtime.RegisterLiveEntity(CreateSpawn(0x70000001u)).Record!; + return runtime.RegisterAndMaterializeProjection( + CreateSpawn(0x70000001u)); } private static WorldSession.EntitySpawn CreateSpawn(uint guid) => diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 663e87c8..59376cce 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -90,8 +90,18 @@ public sealed class GameWindowLiveEntityCompositionTests foreach (FieldInfo field in helperType.GetFields( BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic)) { - Assert.False(typeof(IDictionary).IsAssignableFrom(field.FieldType), - $"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime."); + bool isExactSynchronousProjectionContext = + helperType == typeof(LiveEntityHydrationController) + && field.Name == "_projectionOperations" + && field.FieldType.IsGenericType + && field.FieldType.GetGenericArguments()[0] + == typeof(AcDream.Runtime.Entities.RuntimeEntityRecord); + if (!isExactSynchronousProjectionContext) + { + Assert.False( + typeof(IDictionary).IsAssignableFrom(field.FieldType), + $"{helperType.Name}.{field.Name} must resolve identity through LiveEntityRuntime."); + } string typeName = field.FieldType.FullName ?? field.FieldType.Name; Assert.DoesNotContain("Silk.NET", typeName, StringComparison.Ordinal); Assert.DoesNotContain("OpenGL", typeName, StringComparison.OrdinalIgnoreCase); diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index b4ef508b..4fdf7ada 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -78,14 +78,16 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); - Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); - Assert.Null(record.WorldEntity); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(canonical.LocalEntityId); Assert.Equal(0, fixture.Resources.RegisterCount); Assert.Empty(fixture.Materializer.Calls); fixture.Origin.IsKnownValue = true; fixture.Controller.OnLandblockLoaded(Cell); + LiveEntityRecord record = fixture.Record; Assert.NotNull(record.WorldEntity); Assert.Single(fixture.Materializer.Calls); Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[0].Purpose); @@ -130,11 +132,13 @@ public sealed class LiveEntityHydrationControllerTests fixture.Materializer.SkipMaterializationCount = 1; WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1); fixture.Controller.OnCreate(spawn); - LiveEntityRecord record = fixture.Record; - Assert.Null(record.WorldEntity); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(canonical.LocalEntityId); Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u))); + LiveEntityRecord record = fixture.Record; Assert.NotNull(record.WorldEntity); Assert.Equal(2, fixture.Materializer.Calls.Count); Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[1].Purpose); @@ -516,13 +520,15 @@ public sealed class LiveEntityHydrationControllerTests Assert.Throws(() => fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1))); - Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record)); - Assert.Null(record.WorldEntity); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Null(canonical.LocalEntityId); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); fixture.Controller.OnLandblockLoaded(Cell); + LiveEntityRecord record = fixture.Record; Assert.NotNull(record.WorldEntity); Assert.Equal(2, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -644,7 +650,9 @@ public sealed class LiveEntityHydrationControllerTests ClientObject retainedObject = fixture.Objects.Get(Guid)!; Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 1))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); Assert.Same(retainedObject, fixture.Objects.Get(Guid)); @@ -658,7 +666,9 @@ public sealed class LiveEntityHydrationControllerTests using var fixture = new Fixture(originKnown: true); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 1))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); fixture.Controller.OnLandblockLoaded(Cell); @@ -677,7 +687,9 @@ public sealed class LiveEntityHydrationControllerTests using var fixture = new Fixture(originKnown: true); fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 1))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); Assert.True(fixture.Controller.OnDelete( new DeleteObject.Parsed(Guid, InstanceSequence: 1))); @@ -694,7 +706,9 @@ public sealed class LiveEntityHydrationControllerTests using var fixture = new Fixture(originKnown: true); fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1)); Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 2))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); fixture.Controller.OnCreate(Spawn( Generation: 1, @@ -723,7 +737,9 @@ public sealed class LiveEntityHydrationControllerTests PositionSequence: 5); fixture.Controller.OnCreate(accepted); Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 1))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); CreateObject.ServerPosition stalePosition = accepted.Position!.Value with @@ -760,7 +776,9 @@ public sealed class LiveEntityHydrationControllerTests Value = 123, }); Assert.True(fixture.Controller.OnPrune( - new LiveEntityPruneCandidate(Guid, Generation: 1))); + new LiveEntityPruneCandidate( + fixture.Record.ProjectionKey!.Value, + Guid))); fixture.Controller.OnCreate( Spawn(Generation: 2, PositionSequence: 1) with @@ -831,10 +849,11 @@ public sealed class LiveEntityHydrationControllerTests Assert.True(fixture.Controller.OnDelete( new DeleteObject.Parsed(Guid, InstanceSequence: 1))); - Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord replacement)); - Assert.NotSame(old, replacement); + RuntimeEntityRecord replacement = fixture.Canonical; Assert.Equal((ushort)2, replacement.Generation); Assert.Equal("replacement", replacement.Snapshot.Name); + Assert.Null(replacement.LocalEntityId); + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); Assert.Equal(0, fixture.Runtime.PendingTeardownCount); } @@ -1348,10 +1367,9 @@ public sealed class LiveEntityHydrationControllerTests fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)); - LiveEntityRecord record = fixture.Record; - Assert.False(record.CreateProjectionSynchronizationPending); - Assert.False(record.InitialHydrationCompleted); - Assert.Null(record.WorldEntity); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.Null(canonical.LocalEntityId); + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); Assert.Equal(0, fixture.Resources.RegisterCount); fixture.Materializer.BeforeMaterialize = null; @@ -1371,6 +1389,7 @@ public sealed class LiveEntityHydrationControllerTests }; fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 3)); + LiveEntityRecord record = fixture.Record; Assert.True(record.InitialHydrationCompleted); Assert.NotNull(record.WorldEntity); Assert.Equal(1, fixture.Resources.RegisterCount); @@ -1481,12 +1500,14 @@ public sealed class LiveEntityHydrationControllerTests }; fixture.Relationships.OnSpawnAction = _ => { - LiveEntityRecord record = fixture.Record; - if (record.WorldEntity is not null) + RuntimeEntityRecord canonical = fixture.Canonical; + if (fixture.Runtime.TryGetRecord( + Guid, + out LiveEntityRecord _)) return; WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity( - Guid, + canonical, Cell, id => new WorldEntity { @@ -1498,10 +1519,13 @@ public sealed class LiveEntityHydrationControllerTests MeshRefs = [], ParentCellId = Cell, }, - LiveEntityProjectionKind.Attached); + LiveEntityProjectionKind.Attached, + initializeProjection: null, + out LiveEntityRecord? record); Assert.NotNull(attached); + Assert.NotNull(record); fixture.Controller.OnEntityReady( - LiveEntityReadyCandidate.Capture(record)); + LiveEntityReadyCandidate.Capture(record!)); }; fixture.Controller.OnCreate(CelllessSpawn( @@ -1757,7 +1781,8 @@ public sealed class LiveEntityHydrationControllerTests record, staleVersion, accepted)); - Assert.Empty(fixture.Runtime.MaterializedWorldEntities); + Assert.Single(fixture.Runtime.MaterializedRecords); + Assert.Empty(fixture.Runtime.VisibleRecords); } [Fact] @@ -1890,6 +1915,17 @@ public sealed class LiveEntityHydrationControllerTests } } + public RuntimeEntityRecord Canonical + { + get + { + Assert.True(Runtime.TryGetCanonical( + Guid, + out RuntimeEntityRecord canonical)); + return canonical; + } + } + public void Dispose() { try @@ -1991,24 +2027,24 @@ public sealed class LiveEntityHydrationControllerTests public readonly List<(LiveProjectionPurpose Purpose, ushort Generation)> Calls = []; public readonly List PositionSequences = []; public ulong? InstalledCreateIntegrationVersion { get; private set; } - public Action? BeforeMaterialize { get; set; } - public Action? AfterMaterialize { get; set; } + public Action? BeforeMaterialize { get; set; } + public Action? AfterMaterialize { get; set; } public LiveProjectionPurpose? ThrowAfterMaterializePurposeOnce { get; set; } public int SkipMaterializationCount { get; set; } public bool TryMaterialize( - LiveEntityRecord expectedRecord, + RuntimeEntityRecord expectedCanonical, WorldSession.EntitySpawn canonicalSpawn, LiveProjectionPurpose purpose, ulong expectedCreateIntegrationVersion, AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null) { - Calls.Add((purpose, expectedRecord.Generation)); + Calls.Add((purpose, expectedCanonical.Generation)); PositionSequences.Add(canonicalSpawn.PositionSequence); - operations.Add($"materialize:{purpose}:{expectedRecord.Generation}"); - BeforeMaterialize?.Invoke(expectedRecord, canonicalSpawn); + operations.Add($"materialize:{purpose}:{expectedCanonical.Generation}"); + BeforeMaterialize?.Invoke(expectedCanonical, canonicalSpawn); if (!runtime.IsCurrentCreateIntegration( - expectedRecord, + expectedCanonical, expectedCreateIntegrationVersion)) { return false; @@ -2030,7 +2066,7 @@ public sealed class LiveEntityHydrationControllerTests } WorldEntity? entity = runtime.MaterializeLiveEntity( - canonicalSpawn.Guid, + expectedCanonical, position.LandblockId, id => new WorldEntity { @@ -2041,15 +2077,19 @@ public sealed class LiveEntityHydrationControllerTests Rotation = Quaternion.Identity, MeshRefs = [], ParentCellId = position.LandblockId, - }); + }, + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? expectedRecord); if (ThrowAfterMaterializePurposeOnce == purpose) { ThrowAfterMaterializePurposeOnce = null; throw new InvalidOperationException( "fixture supersession projection failure"); } - AfterMaterialize?.Invoke(expectedRecord, canonicalSpawn); + AfterMaterialize?.Invoke(expectedCanonical, canonicalSpawn); return entity is not null + && expectedRecord is not null && runtime.IsCurrentRecord(expectedRecord); } @@ -2137,7 +2177,7 @@ public sealed class LiveEntityHydrationControllerTests { } - public void OnProjectionRemoved(uint localEntityId) + public void OnProjectionRemoved(LiveEntityRecord record) { } diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 7540111b..53e070e2 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -355,7 +355,7 @@ public sealed class LiveEntityLifecycleStressTests if (record.WorldEntity is { } entity) { cleanups.Add(() => Engine.ShadowObjects.Deregister(entity.Id)); - cleanups.Add(() => _liveLights?.Forget(entity.Id)); + cleanups.Add(() => _liveLights?.Forget(record)); } LiveEntityTeardown.Run(cleanups); }); @@ -402,14 +402,8 @@ public sealed class LiveEntityLifecycleStressTests 8f + (ordinal / 12) * 6f, 10f); WorldSession.EntitySpawn spawn = MissileSpawn(guid, generation, position); - LiveEntityRegistrationResult registration = Runtime.RegisterLiveEntity(spawn); - LiveEntityRecord record = Assert.IsType(registration.Record); - Runtime.SetEffectProfile( - guid, - EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value)); - WorldEntity entity = Assert.IsType(Runtime.MaterializeLiveEntity( - guid, - CellA, + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection( + spawn, localId => new WorldEntity { Id = localId, @@ -419,7 +413,12 @@ public sealed class LiveEntityLifecycleStressTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = CellA, - })); + }, + initializeProjection: exact => + exact.EffectProfile = EntityEffectProfile.CreateLive( + new Setup(), + spawn.Physics!.Value)); + WorldEntity entity = Assert.IsType(record.WorldEntity); Assert.True(Effects.OnLiveEntityReady(guid)); Assert.True(Projectiles.TryBind(record, Setup, 0.0, 1, 1)); @@ -520,13 +519,8 @@ public sealed class LiveEntityLifecycleStressTests Router.Register(Effects); WorldSession.EntitySpawn spawn = RecallSpawn(Guid, CellOne); - Runtime.RegisterLiveEntity(spawn); - Runtime.SetEffectProfile( - Guid, - EntityEffectProfile.CreateLive(new Setup(), spawn.Physics!.Value)); - Entity = Runtime.MaterializeLiveEntity( - Guid, - CellOne, + LiveEntityRecord record = Runtime.RegisterAndMaterializeProjection( + spawn, localId => new WorldEntity { Id = localId, @@ -536,7 +530,12 @@ public sealed class LiveEntityLifecycleStressTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = CellOne, - })!; + }, + initializeProjection: exact => + exact.EffectProfile = EntityEffectProfile.CreateLive( + new Setup(), + spawn.Physics!.Value)); + Entity = Assert.IsType(record.WorldEntity); Assert.True(Effects.OnLiveEntityReady(Guid)); _remote.Body.SnapToCell(CellOne, Entity.Position, Entity.Position); diff --git a/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs index 2d473658..e3b9b7ed 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLivenessControllerTests.cs @@ -1,5 +1,6 @@ using AcDream.App.World; using AcDream.Core.Net.Messages; +using AcDream.Runtime.Entities; namespace AcDream.App.Tests.World; @@ -14,7 +15,9 @@ public sealed class LiveEntityLivenessControllerTests Assert.Empty(tracker.Tick(10.0, samples)); Assert.Empty(tracker.Tick(34.999, samples)); Assert.Equal( - new LiveEntityPruneCandidate(0x7000_0001u, 4), + new LiveEntityPruneCandidate( + new RuntimeEntityKey(0x7000_0001u, 4), + 0x7000_0001u), Assert.Single(tracker.Tick(35.0, samples))); Assert.Equal(0, tracker.DeadlineCount); } @@ -47,7 +50,7 @@ public sealed class LiveEntityLivenessControllerTests Assert.Empty(tracker.Tick(24.0, [Sample(1, 2, visible: false)])); Assert.Empty(tracker.Tick(25.0, [Sample(1, 2, visible: false)])); Assert.Equal( - new LiveEntityPruneCandidate(1, 2), + new LiveEntityPruneCandidate(new RuntimeEntityKey(1, 2), 1), Assert.Single(tracker.Tick(49.0, [Sample(1, 2, visible: false)]))); } @@ -80,7 +83,11 @@ public sealed class LiveEntityLivenessControllerTests ushort generation, bool visible, bool retained = false) => - new(guid, generation, visible, retained); + new( + new RuntimeEntityKey(guid, generation), + guid, + visible, + retained); private static CreateObject.ServerPosition Position( uint cell, diff --git a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs index dac86aa2..fa5fca3d 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPhysicsHostOwnershipTests.cs @@ -6,6 +6,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; +using AcDream.Core.World; namespace AcDream.App.Tests.World; @@ -16,7 +17,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000010u; var runtime = Runtime(); - LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var firstMotion = new HostConsumerMotion(); runtime.SetRemoteMotionRuntime(guid, firstMotion); EntityPhysicsHost firstHost = Host(guid); @@ -26,7 +27,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); Assert.Same(firstHost, resolved); - LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + LiveEntityRecord second = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2)); var secondMotion = new HostConsumerMotion(); runtime.SetRemoteMotionRuntime(guid, secondMotion); EntityPhysicsHost replacement = Host(guid); @@ -42,7 +43,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000011u; var runtime = Runtime(); - LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); EntityPhysicsHost original = Host(guid); runtime.InstallPhysicsHost(first, original); @@ -51,7 +52,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.Throws(() => runtime.InstallPhysicsHost(first, Host(guid))); - LiveEntityRecord second = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + LiveEntityRecord second = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2)); Assert.Throws(() => runtime.InstallPhysicsHost(first, Host(guid))); Assert.Null(second.PhysicsHost); @@ -63,8 +64,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests const uint watcherGuid = 0x70000020u; const uint targetGuid = 0x70000021u; var runtime = Runtime(); - LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; - LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection(Spawn(watcherGuid, 1)); + LiveEntityRecord targetRecord = runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, 1)); IPhysicsObjHost? Resolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host @@ -129,7 +130,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000025u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); EntityPhysicsHost original = Host( guid, position: new Vector3(1f, 0f, 0f)); @@ -163,7 +164,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000022u; var runtime = Runtime(); - LiveEntityRecord first = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord first = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); int firstUpdates = 0; int firstInterrupts = 0; var firstHost = CallbackHost( @@ -172,7 +173,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests () => firstInterrupts++); runtime.InstallPhysicsHost(first, firstHost); - LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2)); int replacementUpdates = 0; int replacementInterrupts = 0; var replacementHost = CallbackHost( @@ -195,7 +196,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000030u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var motion = new RemoteMotion(new PhysicsBody()); runtime.SetRemoteMotionRuntime(guid, motion); EntityPhysicsHost minimal = Host(guid); @@ -221,7 +222,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000031u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var failing = new ThrowingCellConsumerMotion(); Assert.Throws(() => @@ -240,11 +241,11 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000032u; var runtime = Runtime(); - runtime.RegisterLiveEntity(Spawn(guid, 1)); + runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var stale = new RemoteMotion(new PhysicsBody()); runtime.SetRemoteMotionRuntime(guid, stale); - LiveEntityRecord replacement = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; + LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2)); Assert.Throws(() => runtime.SetRemoteMotionRuntime(guid, stale)); Assert.Null(replacement.RemoteMotionRuntime); @@ -261,7 +262,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000033u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var firstBody = new PhysicsBody(); var secondBody = new PhysicsBody(); var alternating = new AlternatingBodyMotion(firstBody, secondBody); @@ -282,7 +283,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000036u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var canonical = new PhysicsBody(); var unrelated = new PhysicsBody(); PhysicsStateFlags unrelatedInitialState = unrelated.State; @@ -302,17 +303,18 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000034u; var runtime = Runtime(); - LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord retired = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var motion = new CallbackCanonicalMotion(() => runtime.RegisterLiveEntity(Spawn(guid, 2))); Assert.Throws(() => runtime.SetRemoteMotionRuntime(guid, motion)); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); - Assert.Equal((ushort)2, replacement.Generation); - Assert.Null(replacement.RemoteMotionRuntime); + Assert.True(runtime.TryGetCanonical(guid, out var replacement)); + Assert.Equal((ushort)2, replacement.Incarnation); + Assert.Null(replacement.LocalEntityId); Assert.Null(replacement.PhysicsBody); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Null(retired.RemoteMotionRuntime); Assert.Null(retired.PhysicsBody); Assert.False(runtime.TryGetRemoteMotionRuntime(guid, out _)); @@ -323,7 +325,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000035u; var runtime = Runtime(); - LiveEntityRecord retired = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord retired = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); var motion = new CallbackCanonicalMotion(runtime.Clear); Assert.Throws(() => @@ -343,8 +345,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests const uint rightGuid = 0x70000041u; LiveEntityRuntime runtime = null!; runtime = Runtime(record => CleanPhysicsHost(record)); - LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; - LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + LiveEntityRecord leftRecord = runtime.RegisterAndMaterializeProjection(Spawn(leftGuid, 1)); + LiveEntityRecord rightRecord = runtime.RegisterAndMaterializeProjection(Spawn(rightGuid, 1)); IPhysicsObjHost? Resolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host @@ -374,8 +376,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests const uint rightGuid = 0x70000043u; LiveEntityRuntime runtime = null!; runtime = Runtime(record => CleanPhysicsHost(record)); - LiveEntityRecord leftRecord = runtime.RegisterLiveEntity(Spawn(leftGuid, 1)).Record!; - LiveEntityRecord rightRecord = runtime.RegisterLiveEntity(Spawn(rightGuid, 1)).Record!; + LiveEntityRecord leftRecord = runtime.RegisterAndMaterializeProjection(Spawn(leftGuid, 1)); + LiveEntityRecord rightRecord = runtime.RegisterAndMaterializeProjection(Spawn(rightGuid, 1)); IPhysicsObjHost? Resolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host @@ -410,21 +412,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests if (failOnce) { failOnce = false; - replacementRecord = runtime.RegisterLiveEntity(Spawn(guid, 2)).Record!; - IPhysicsObjHost? Resolve(uint id) => - runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) - ? host - : null; - replacementHost = Host(guid, Resolve); - runtime.InstallPhysicsHost(replacementRecord, replacementHost); - replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); + runtime.RegisterLiveEntity(Spawn(guid, 2)); throw new InvalidOperationException("fixture failure before old host cleanup"); } CleanPhysicsHost(record); }); - LiveEntityRecord oldRecord = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; - LiveEntityRecord targetRecord = runtime.RegisterLiveEntity(Spawn(targetGuid, 1)).Record!; + LiveEntityRecord oldRecord = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); + LiveEntityRecord targetRecord = runtime.RegisterAndMaterializeProjection(Spawn(targetGuid, 1)); IPhysicsObjHost? InitialResolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host @@ -438,6 +433,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.Throws(() => runtime.UnregisterLiveEntity( new DeleteObject.Parsed(guid, InstanceSequence: 1), isLocalPlayer: false)); + replacementRecord = runtime.RegisterAndMaterializeProjection(Spawn(guid, 2)); + IPhysicsObjHost? ResolveReplacement(uint id) => + runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) + ? host + : null; + replacementHost = Host(guid, ResolveReplacement); + runtime.InstallPhysicsHost(replacementRecord, replacementHost); + replacementHost.PositionManager.StickTo(targetGuid, 0.5f, 1f); Assert.Same(oldHost, oldRecord.PhysicsHost); Assert.Same(replacementHost, replacementRecord!.PhysicsHost); Assert.Equal(targetGuid, replacementHost!.PositionManager.GetStickyObjectId()); @@ -466,8 +469,8 @@ public sealed class LiveEntityPhysicsHostOwnershipTests throw new InvalidOperationException("fixture post-exit failure"); } }); - LiveEntityRecord retiredRecord = runtime.RegisterLiveEntity(Spawn(retiredGuid, 1)).Record!; - LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; + LiveEntityRecord retiredRecord = runtime.RegisterAndMaterializeProjection(Spawn(retiredGuid, 1)); + LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection(Spawn(watcherGuid, 1)); IPhysicsObjHost? Resolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host @@ -493,7 +496,7 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x70000048u; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); record.FullCellId = 0x01010123u; record.WorldEntity = new AcDream.Core.World.WorldEntity { @@ -521,32 +524,31 @@ public sealed class LiveEntityPhysicsHostOwnershipTests runtime = Runtime(record => { if (record.ServerGuid == departingGuid) - { - LiveEntityRecord replacement = runtime.RegisterLiveEntity( - Spawn(departingGuid, 2)).Record!; - replacement.FullCellId = 0x02020202u; - replacement.WorldEntity = Entity( - departingGuid, - new Vector3(999f, 999f, 999f), - Quaternion.Identity); - runtime.InstallPhysicsHost(replacement, Host(departingGuid)); - } + runtime.RegisterLiveEntity(Spawn(departingGuid, 2)); CleanPhysicsHost(record); }); - LiveEntityRecord departing = runtime.RegisterLiveEntity(Spawn(departingGuid, 1)).Record!; - LiveEntityRecord watcherRecord = runtime.RegisterLiveEntity(Spawn(watcherGuid, 1)).Record!; - departing.FullCellId = 0x01010123u; Quaternion departingRotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); - departing.WorldEntity = Entity( - departingGuid, - new Vector3(12f, 34f, 56f), - departingRotation); - watcherRecord.WorldEntity = Entity(watcherGuid, Vector3.Zero, Quaternion.Identity); + LiveEntityRecord departing = runtime.RegisterAndMaterializeProjection( + Spawn(departingGuid, 1), + localId => Entity( + localId, + departingGuid, + new Vector3(12f, 34f, 56f), + departingRotation)); + departing.FullCellId = 0x01010123u; + WorldEntity departingEntity = Assert.IsType(departing.WorldEntity); + LiveEntityRecord watcherRecord = runtime.RegisterAndMaterializeProjection( + Spawn(watcherGuid, 1), + localId => Entity( + localId, + watcherGuid, + Vector3.Zero, + Quaternion.Identity)); IPhysicsObjHost? Resolve(uint id) => runtime.TryGetPhysicsHost(id, out IPhysicsObjHost host) ? host : null; var body = new PhysicsBody { - Position = departing.WorldEntity.Position, + Position = departingEntity.Position, Orientation = departingRotation, }; var remote = new RemoteMotion(body); @@ -586,6 +588,14 @@ public sealed class LiveEntityPhysicsHostOwnershipTests Assert.True(runtime.UnregisterLiveEntity( new DeleteObject.Parsed(departingGuid, InstanceSequence: 1), isLocalPlayer: false)); + LiveEntityRecord replacement = runtime.RegisterAndMaterializeProjection( + Spawn(departingGuid, 2)); + replacement.FullCellId = 0x02020202u; + runtime.InstallPhysicsHost( + replacement, + Host( + departingGuid, + position: new Vector3(999f, 999f, 999f))); TargetInfo exit = Assert.Single(delivered); Assert.Equal(TargetStatus.ExitWorld, exit.Status); @@ -600,11 +610,11 @@ public sealed class LiveEntityPhysicsHostOwnershipTests { const uint guid = 0x7000004Bu; var runtime = Runtime(); - LiveEntityRecord record = runtime.RegisterLiveEntity(Spawn(guid, 1)).Record!; + LiveEntityRecord record = runtime.RegisterAndMaterializeProjection(Spawn(guid, 1)); EntityPhysicsHost host = EntityPhysicsHost.CreateMinimal(record, _ => null, () => 0); runtime.InstallPhysicsHost(record, host); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.True(runtime.TryGetPhysicsHost(guid, out IPhysicsObjHost resolved)); Assert.Same(host, resolved); } @@ -645,12 +655,13 @@ public sealed class LiveEntityPhysicsHostOwnershipTests InstanceSequence: instance); private static AcDream.Core.World.WorldEntity Entity( + uint localId, uint guid, Vector3 position, Quaternion rotation) => new() { - Id = 1_000_000u + (guid & 0xFFFFu), + Id = localId, ServerGuid = guid, SourceGfxObjOrSetupId = 0x02000001u, Position = position, diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index 5c272886..671aa756 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -73,7 +73,9 @@ public sealed class LiveEntityPresentationControllerTests fixture.PresentationOrder); Assert.Equal(1, fixture.Shadows.TotalRegistered); Assert.True(fixture.Entity.IsDrawVisible); - Assert.Same(fixture.Entity, Assert.Single(fixture.Runtime.WorldEntities).Value); + Assert.Same( + fixture.Entity, + Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity); } [Fact] @@ -289,7 +291,9 @@ public sealed class LiveEntityPresentationControllerTests Assert.Equal(0, fixture.Shadows.TotalRegistered); Assert.True(fixture.Runtime.RebucketLiveEntity(Fixture.Guid, 0x02020001u)); - Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid)); + Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity( + Fixture.Guid, + out _)); Assert.True(fixture.Runtime.TryApplyState( new SetState.Parsed(Fixture.Guid, 0u, 1, 3), @@ -303,7 +307,9 @@ public sealed class LiveEntityPresentationControllerTests new LandBlock(), Array.Empty())); - Assert.True(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid)); + Assert.True(fixture.Runtime.TryGetInteractionEligibleEntity( + Fixture.Guid, + out _)); Assert.Equal(1, fixture.Shadows.TotalRegistered); } @@ -318,7 +324,9 @@ public sealed class LiveEntityPresentationControllerTests Fixture.Guid, 0x02020001u)); - Assert.False(fixture.Runtime.WorldEntities.ContainsKey(Fixture.Guid)); + Assert.False(fixture.Runtime.TryGetInteractionEligibleEntity( + Fixture.Guid, + out _)); Assert.Equal(0, fixture.Shadows.TotalRegistered); Assert.Equal(1, fixture.Shadows.RetainedRegistrationCount); Assert.Equal(1, fixture.Shadows.SuspendedRegistrationCount); @@ -331,7 +339,7 @@ public sealed class LiveEntityPresentationControllerTests Assert.Same( fixture.Entity, - Assert.Single(fixture.Runtime.WorldEntities).Value); + Assert.Single(fixture.Runtime.VisibleRecords).WorldEntity); Assert.Equal(1, fixture.Shadows.TotalRegistered); Assert.Equal(0, fixture.Shadows.SuspendedRegistrationCount); Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); @@ -467,7 +475,7 @@ public sealed class LiveEntityPresentationControllerTests new LandBlock(), Array.Empty())); - Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Equal(1, shadows.TotalRegistered); Assert.Equal(0, shadows.SuspendedRegistrationCount); Assert.False(controller.HasDeferredShadowRestore(Fixture.Guid)); diff --git a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs index 42bbc4fd..91e92651 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityProjectionWithdrawalControllerTests.cs @@ -247,10 +247,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests MotionState: null, MotionTableId: null, InstanceSequence: instance); - LiveEntityRecord record = Live.RegisterLiveEntity(spawn).Record!; - WorldEntity entity = Live.MaterializeLiveEntity( - Guid, - Cell, + LiveEntityRecord record = Live.RegisterAndMaterializeProjection( + spawn, id => new WorldEntity { Id = id, @@ -260,7 +258,8 @@ public sealed class LiveEntityProjectionWithdrawalControllerTests Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), ParentCellId = Cell, - })!; + }); + WorldEntity entity = Assert.IsType(record.WorldEntity); var snapshot = new WorldEntitySnapshot( entity.Id, entity.SourceGfxObjOrSetupId, diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs index c03639bf..6fbeb7d4 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTeardownControllerTests.cs @@ -11,7 +11,8 @@ public sealed class LiveEntityRuntimeTeardownControllerTests [Fact] public void Retry_ReusesPlanAndDoesNotReplayCompletedOwners() { - LiveEntityRecord record = new(Spawn()); + LiveEntityRecord record = + LiveEntityTestFixture.CreateExactProjectionRecord(Spawn()); int factoryCalls = 0; int completedOwnerCalls = 0; int retryingOwnerCalls = 0; diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index f135f334..3e0a57f3 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -196,7 +196,7 @@ public sealed class LiveEntityRuntimeTests Assert.Single(spatial.Entities); Assert.True(runtime.WithdrawLiveEntityProjection(spawn.Guid)); Assert.Empty(spatial.Entities); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Equal(1, resources.RegisterCount); Assert.Equal(0, resources.UnregisterCount); Assert.True(runtime.TryGetAnimationRuntime(entity!.Id, out _)); @@ -424,6 +424,7 @@ public sealed class LiveEntityRuntimeTests guid, 0x01010001u, id => Entity(id, guid))!; + Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); var animation = new AnimationRuntime(entity); var remote = new RemoteMotionRuntime(); var projectile = new ProjectileRuntime(remote.Body); @@ -431,9 +432,9 @@ public sealed class LiveEntityRuntimeTests runtime.SetRemoteMotionRuntime(guid, remote); runtime.SetProjectileRuntime(guid, projectile); - Assert.Same(animation, Assert.Single(runtime.SpatialAnimationRuntimes).Value); - Assert.Same(remote, Assert.Single(runtime.SpatialRemoteMotionRuntimes).Value); - Assert.Same(projectile, Assert.Single(runtime.SpatialProjectileRuntimes).Value); + Assert.True(runtime.IsCurrentSpatialAnimation(record, animation)); + Assert.True(runtime.IsCurrentSpatialRemoteMotion(record, remote)); + Assert.True(runtime.IsCurrentSpatialProjectile(record, projectile)); // Hidden suppresses presentation, not the live CPhysicsObj workset. Assert.True(runtime.TryApplyState(new SetState.Parsed( @@ -441,16 +442,16 @@ public sealed class LiveEntityRuntimeTests (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions), 1, 2), out _)); - Assert.Single(runtime.SpatialAnimationRuntimes); - Assert.Single(runtime.SpatialRemoteMotionRuntimes); - Assert.Single(runtime.SpatialProjectileRuntimes); + Assert.Equal(1, runtime.SpatialAnimationRuntimeCount); + Assert.Equal(1, runtime.SpatialRemoteMotionRuntimeCount); + Assert.Equal(1, runtime.SpatialProjectileRuntimeCount); // A pending projection retains every logical component but disappears // from all per-frame worksets. Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); - Assert.Empty(runtime.SpatialAnimationRuntimes); - Assert.Empty(runtime.SpatialRemoteMotionRuntimes); - Assert.Empty(runtime.SpatialProjectileRuntimes); + Assert.Equal(0, runtime.SpatialAnimationRuntimeCount); + Assert.Equal(0, runtime.SpatialRemoteMotionRuntimeCount); + Assert.Equal(0, runtime.SpatialProjectileRuntimeCount); Assert.True(runtime.TryGetAnimationRuntime(entity.Id, out var retainedAnimation)); Assert.Same(animation, retainedAnimation); Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var retainedRemote)); @@ -459,14 +460,14 @@ public sealed class LiveEntityRuntimeTests Assert.Same(projectile, retainedProjectile); spatial.AddLandblock(EmptyLandblock(0x0202FFFFu)); - Assert.Single(runtime.SpatialAnimationRuntimes); - Assert.Single(runtime.SpatialRemoteMotionRuntimes); - Assert.Single(runtime.SpatialProjectileRuntimes); + Assert.Equal(1, runtime.SpatialAnimationRuntimeCount); + Assert.Equal(1, runtime.SpatialRemoteMotionRuntimeCount); + Assert.Equal(1, runtime.SpatialProjectileRuntimeCount); Assert.True(runtime.WithdrawLiveEntityProjection(guid)); - Assert.Empty(runtime.SpatialAnimationRuntimes); - Assert.Empty(runtime.SpatialRemoteMotionRuntimes); - Assert.Empty(runtime.SpatialProjectileRuntimes); + Assert.Equal(0, runtime.SpatialAnimationRuntimeCount); + Assert.Equal(0, runtime.SpatialRemoteMotionRuntimeCount); + Assert.Equal(0, runtime.SpatialProjectileRuntimeCount); } [Fact] @@ -484,22 +485,25 @@ public sealed class LiveEntityRuntimeTests var animation = new AnimationRuntime(entity); runtime.SetAnimationRuntime(guid, animation); var view = new LiveEntityAnimationRuntimeView(BoundSlot(runtime)); + var spatialIds = new HashSet(); Assert.Equal(1, view.Count); - Assert.Equal(entity.Id, Assert.Single(view.Keys)); + view.CopySpatialIdsTo(spatialIds); + Assert.Equal(entity.Id, Assert.Single(spatialIds)); Assert.Same(animation, Assert.Single(view).Value); Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u)); Assert.Equal(0, view.Count); - Assert.Empty(view.Keys); + view.CopySpatialIdsTo(spatialIds); + Assert.Empty(spatialIds); Assert.Empty(view); Assert.True(view.TryGetValue(entity.Id, out AnimationRuntime retained)); Assert.Same(animation, retained); Assert.True(view.Remove(entity.Id)); Assert.False(view.TryGetValue(entity.Id, out _)); - Assert.Empty(runtime.AnimationRuntimes); - Assert.Empty(runtime.SpatialAnimationRuntimes); + Assert.Equal(0, runtime.AnimationRuntimeCount); + Assert.Equal(0, runtime.SpatialAnimationRuntimeCount); } [Fact] @@ -531,8 +535,8 @@ public sealed class LiveEntityRuntimeTests } Assert.Single(visited); - Assert.Single(runtime.SpatialAnimationRuntimes); - Assert.Equal(2, runtime.AnimationRuntimes.Count); + Assert.Equal(1, runtime.SpatialAnimationRuntimeCount); + Assert.Equal(2, runtime.AnimationRuntimeCount); } [Fact] @@ -601,10 +605,10 @@ public sealed class LiveEntityRuntimeTests Assert.False(runtime.WithdrawLiveEntityProjection(guid)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); Assert.Equal((ushort)2, current.Generation); - KeyValuePair indexed = - Assert.Single(runtime.SpatialAnimationRuntimes); - Assert.Same(current.AnimationRuntime, indexed.Value); - Assert.NotSame(oldAnimation, indexed.Value); + Assert.True(runtime.IsCurrentSpatialAnimation( + current, + current.AnimationRuntime!)); + Assert.NotSame(oldAnimation, current.AnimationRuntime); } [Fact] @@ -670,13 +674,18 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); WorldSession.EntitySpawn spawn = Spawn(guid, 6, 1, 0x01010001u); - runtime.RegisterLiveEntity(spawn); + LiveEntityRegistrationResult registration = + runtime.RegisterLiveEntity(spawn); + RuntimeEntityRecord canonical = Assert.IsType( + registration.Canonical); var profile = new EffectProfile(); - runtime.SetEffectProfile(guid, profile); runtime.MaterializeLiveEntity( - guid, + canonical, spawn.Position!.Value.LandblockId, - id => Entity(id, guid)); + id => Entity(id, guid), + LiveEntityProjectionKind.World, + initializeProjection: record => record.EffectProfile = profile, + out _); Assert.True(runtime.RebucketLiveEntity(guid, 0x01020001u)); Assert.True(runtime.TryGetEffectProfile(guid, out var retained)); @@ -865,7 +874,7 @@ public sealed class LiveEntityRuntimeTests LiveEntityProjectionKind.Attached)!; Assert.Single(spatial.Entities); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.True(runtime.TryGetWorldEntity(spawn.Guid, out WorldEntity resolved)); Assert.Same(attached, resolved); @@ -880,7 +889,7 @@ public sealed class LiveEntityRuntimeTests Assert.Same(attached, same); Assert.True(same.IsAncestorDrawVisible); - Assert.Same(attached, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(attached, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.True(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.False(runtime.TryMarkWorldSpawnPublished(spawn.Guid)); Assert.Equal(1, resources.RegisterCount); @@ -902,12 +911,12 @@ public sealed class LiveEntityRuntimeTests Assert.Empty(spatial.Entities); Assert.Equal(1, spatial.PendingLiveEntityCount); Assert.Equal(1, resources.RegisterCount); - Assert.Empty(runtime.WorldEntities); - Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Empty(runtime.VisibleRecords); + Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity); spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); Assert.Same(entity, Assert.Single(spatial.Entities)); - Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.True(runtime.RebucketLiveEntity(spawn.Guid, 0x01020001u)); Assert.Same(entity, Assert.Single(spatial.Entities)); Assert.Equal(1, resources.RegisterCount); @@ -928,8 +937,8 @@ public sealed class LiveEntityRuntimeTests spawn.Position!.Value.LandblockId, id => Entity(id, guid))!; - Assert.Empty(runtime.WorldEntities); - Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Empty(runtime.VisibleRecords); + Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity); Assert.True(runtime.TryApplyVector( new VectorUpdate.Parsed( guid, @@ -960,8 +969,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(PositionTimestampDisposition.Apply, disposition); Assert.True(runtime.RebucketLiveEntity(guid, accepted.Position!.Value.LandblockId)); - Assert.Empty(runtime.WorldEntities); - Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Empty(runtime.VisibleRecords); + Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity); Assert.Equal(1, resources.RegisterCount); Assert.Equal(0, resources.UnregisterCount); Assert.Equal(1, spatial.PendingLiveEntityCount); @@ -1097,11 +1106,7 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u); - LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; - runtime.MaterializeLiveEntity( - guid, - spawn.Position!.Value.LandblockId, - id => Entity(id, guid)); + LiveEntityRecord record = RegisterProjection(runtime, spawn); RetailObjectQuantumClock clock = record.ObjectClock; Assert.Equal(0, clock.Advance(0.02).Count); @@ -1145,15 +1150,10 @@ public sealed class LiveEntityRuntimeTests 1, 0x01010001u, PhysicsStateFlags.Static); - LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!; + LiveEntityRecord record = RegisterProjection(runtime, spawn); var remote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(guid, remote); - runtime.MaterializeLiveEntity( - guid, - spawn.Position!.Value.LandblockId, - id => Entity(id, guid)); - Assert.False(record.ObjectClock.IsActive); Assert.Equal(0.0, record.ObjectClock.PendingSeconds, 8); Assert.False(remote.Body.TransientState.HasFlag(TransientStateFlags.Active)); @@ -1168,14 +1168,11 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); - LiveEntityRecord ordinary = runtime.RegisterLiveEntity( - Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!; + LiveEntityRecord ordinary = RegisterProjection( + runtime, + Spawn(ordinaryGuid, 1, 1, 0x01010001u)); var ordinaryRemote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote); - runtime.MaterializeLiveEntity( - ordinaryGuid, - 0x01010001u, - id => Entity(id, ordinaryGuid)); ordinary.ObjectClock.Deactivate(); ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active; @@ -1183,14 +1180,16 @@ public sealed class LiveEntityRuntimeTests Assert.True(ordinary.ObjectClock.IsActive); Assert.True(ordinaryRemote.Body.TransientState.HasFlag(TransientStateFlags.Active)); - LiveEntityRecord staticRecord = runtime.RegisterLiveEntity( - Spawn(staticGuid, 1, 1, 0x01010001u, PhysicsStateFlags.Static)).Record!; + LiveEntityRecord staticRecord = RegisterProjection( + runtime, + Spawn( + staticGuid, + 1, + 1, + 0x01010001u, + PhysicsStateFlags.Static)); var staticRemote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(staticGuid, staticRemote); - runtime.MaterializeLiveEntity( - staticGuid, - 0x01010001u, - id => Entity(id, staticGuid)); Assert.False(runtime.TryActivateOrdinaryObject(staticGuid, staticRemote)); Assert.False(staticRecord.ObjectClock.IsActive); @@ -1207,14 +1206,11 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); - LiveEntityRecord ordinary = runtime.RegisterLiveEntity( - Spawn(ordinaryGuid, 1, 1, 0x01010001u)).Record!; + LiveEntityRecord ordinary = RegisterProjection( + runtime, + Spawn(ordinaryGuid, 1, 1, 0x01010001u)); var ordinaryRemote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(ordinaryGuid, ordinaryRemote); - runtime.MaterializeLiveEntity( - ordinaryGuid, - 0x01010001u, - id => Entity(id, ordinaryGuid)); ordinary.ObjectClock.Deactivate(); ordinaryRemote.Body.TransientState &= ~TransientStateFlags.Active; ordinaryRemote.Body.LastUpdateTime = 1.0; @@ -1234,14 +1230,11 @@ public sealed class LiveEntityRuntimeTests // Defensive split state: the retained object clock is already active, // but a body consumer cleared its legacy Active bit. set_velocity must // still rebase that body's compatibility timestamp. - LiveEntityRecord defensive = runtime.RegisterLiveEntity( - Spawn(defensiveGuid, 1, 1, 0x01010001u)).Record!; + LiveEntityRecord defensive = RegisterProjection( + runtime, + Spawn(defensiveGuid, 1, 1, 0x01010001u)); var defensiveRemote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(defensiveGuid, defensiveRemote); - runtime.MaterializeLiveEntity( - defensiveGuid, - 0x01010001u, - id => Entity(id, defensiveGuid)); Assert.True(defensive.ObjectClock.IsActive); defensiveRemote.Body.TransientState &= ~TransientStateFlags.Active; defensiveRemote.Body.LastUpdateTime = 2.0; @@ -1255,19 +1248,16 @@ public sealed class LiveEntityRuntimeTests Assert.True(defensiveRemote.Body.IsActive); Assert.Equal(9.0, defensiveRemote.Body.LastUpdateTime); - LiveEntityRecord staticRecord = runtime.RegisterLiveEntity( + LiveEntityRecord staticRecord = RegisterProjection( + runtime, Spawn( staticGuid, 1, 1, 0x01010001u, - PhysicsStateFlags.Static)).Record!; + PhysicsStateFlags.Static)); var staticRemote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(staticGuid, staticRemote); - runtime.MaterializeLiveEntity( - staticGuid, - 0x01010001u, - id => Entity(id, staticGuid)); staticRemote.Body.TransientState &= ~TransientStateFlags.Active; staticRemote.Body.LastUpdateTime = 3.0; @@ -1291,8 +1281,9 @@ public sealed class LiveEntityRuntimeTests const uint childGuid = 0x70000055u; var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); runtime.RegisterLiveEntity(Spawn(parentGuid, 1, 1, 0x01010001u)); - LiveEntityRecord child = runtime.RegisterLiveEntity( - Spawn(childGuid, 1, 1, 0x01010001u)).Record!; + LiveEntityRecord child = RegisterProjection( + runtime, + Spawn(childGuid, 1, 1, 0x01010001u)); ulong initialPosition = child.PositionAuthorityVersion; ulong initialVelocity = child.VelocityAuthorityVersion; @@ -1325,11 +1316,7 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); WorldSession.EntitySpawn firstSpawn = Spawn(guid, 1, 1, 0x01010001u); - LiveEntityRecord first = runtime.RegisterLiveEntity(firstSpawn).Record!; - runtime.MaterializeLiveEntity( - guid, - firstSpawn.Position!.Value.LandblockId, - id => Entity(id, guid)); + LiveEntityRecord first = RegisterProjection(runtime, firstSpawn); RetailObjectQuantumClock firstClock = first.ObjectClock; Assert.Equal(0, firstClock.Advance(0.02).Count); @@ -1341,7 +1328,7 @@ public sealed class LiveEntityRuntimeTests new DeleteObject.Parsed(guid, InstanceSequence: 1), isLocalPlayer: false)); WorldSession.EntitySpawn secondSpawn = Spawn(guid, 2, 2, 0x01010001u); - LiveEntityRecord second = runtime.RegisterLiveEntity(secondSpawn).Record!; + LiveEntityRecord second = RegisterProjection(runtime, secondSpawn); Assert.NotSame(first, second); Assert.NotSame(firstClock, second.ObjectClock); @@ -1364,9 +1351,17 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryApplyState( new SetState.Parsed(stateBeforeBindGuid, (uint)firstState, 1, 2), out _)); + runtime.MaterializeLiveEntity( + stateBeforeBindGuid, + 0x01010001u, + id => Entity(id, stateBeforeBindGuid)); var lateBody = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(stateBeforeBindGuid, lateBody); + runtime.MaterializeLiveEntity( + bindBeforeStateGuid, + 0x01010001u, + id => Entity(id, bindBeforeStateGuid)); var earlyBody = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(bindBeforeStateGuid, earlyBody); PhysicsStateFlags secondState = PhysicsStateFlags.Static @@ -1388,6 +1383,10 @@ public sealed class LiveEntityRuntimeTests const uint guid = 0x70000039u; var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid)); var remote = new RemoteMotionRuntime(); runtime.SetRemoteMotionRuntime(guid, remote); @@ -1404,9 +1403,10 @@ public sealed class LiveEntityRuntimeTests const uint guid = 0x70000040u; var runtime = new LiveEntityRuntime(new GpuWorldState(), new RecordingResources()); - runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); + LiveEntityRecord record = RegisterProjection( + runtime, + Spawn(guid, 1, 1, 0x01010001u)); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.True(record.TryDequeueStateTransition(out RetailPhysicsStateTransition transition)); Assert.Equal(RetailHiddenTransition.None, transition.HiddenTransition); Assert.Equal(PhysicsStateFlags.ReportCollisions, record.FinalPhysicsState); @@ -1431,8 +1431,8 @@ public sealed class LiveEntityRuntimeTests id => Entity(id, guid))!; Assert.False(entity.IsDrawVisible); - Assert.Empty(runtime.WorldEntities); - Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Empty(runtime.VisibleRecords); + Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity); Assert.Single(spatial.Entities); Assert.True(runtime.ShouldAdvanceRootRuntime(guid)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); @@ -1462,7 +1462,7 @@ public sealed class LiveEntityRuntimeTests 2), out _, out RetailPhysicsStateTransition hidden)); Assert.Equal(RetailHiddenTransition.BecameHidden, hidden.HiddenTransition); Assert.False(entity.IsDrawVisible); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.False(runtime.TryGetInteractionEligibleEntity(guid, out _)); Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id, out _)); @@ -1478,7 +1478,7 @@ public sealed class LiveEntityRuntimeTests Assert.Same(entity, eligibleRecord.WorldEntity); Assert.False(runtime.TryGetInteractionEligibleRecord(guid, entity.Id + 1u, out _)); Assert.Same(entity, eligible); - Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(entity, Assert.Single(spatial.Entities)); } @@ -1573,7 +1573,7 @@ public sealed class LiveEntityRuntimeTests spatial.RemoveLandblock(0x0101FFFFu); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)); Assert.True(record.IsSpatiallyProjected); Assert.False(record.IsSpatiallyVisible); @@ -1582,7 +1582,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0, resources.UnregisterCount); spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); - Assert.Same(entity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(entity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.True(record.IsSpatiallyVisible); Assert.Equal(1, resources.RegisterCount); } @@ -1632,7 +1632,7 @@ public sealed class LiveEntityRuntimeTests runtime.Clear(); Assert.Equal(0, runtime.Count); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -1654,10 +1654,11 @@ public sealed class LiveEntityRuntimeTests spawn.Position!.Value.LandblockId, id => Entity(id, spawn.Guid))); - Assert.True(runtime.TryGetRecord(spawn.Guid, out LiveEntityRecord record)); - Assert.Null(record.WorldEntity); + Assert.False(runtime.TryGetRecord(spawn.Guid, out _)); + RuntimeEntityRecord canonical = CurrentCanonical(runtime, spawn.Guid); + Assert.Null(canonical.LocalEntityId); Assert.Equal(0, runtime.MaterializedCount); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -1724,7 +1725,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(1, runtime.RetryPendingTeardowns()); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); Assert.Same(replacement, current.WorldEntity); - Assert.Same(replacement, Assert.Single(runtime.MaterializedWorldEntities).Value); + Assert.Same(replacement, Assert.Single(runtime.MaterializedRecords).WorldEntity); Assert.Equal(0, runtime.PendingTeardownCount); } @@ -1746,7 +1747,7 @@ public sealed class LiveEntityRuntimeTests LiveEntityRegistrationResult retransmit = runtime.RegisterLiveEntity(spawn); Assert.False(retransmit.LogicalRegistrationCreated); - Assert.Null(retransmit.Record); + Assert.Null(retransmit.Projection); Assert.Equal(0, runtime.Count); Assert.Equal(1, runtime.PendingTeardownCount); @@ -1780,7 +1781,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0, runtime.Count); Assert.Equal(1, runtime.PendingTeardownCount); Assert.Equal(1, runtime.MaterializedCount); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); } @@ -1898,9 +1899,10 @@ public sealed class LiveEntityRuntimeTests Spawn(guid, 2, 1, 0x01010001u)); Assert.NotNull(replacement.PriorGenerationCleanupFailure); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); - Assert.Equal((ushort)2, record.Generation); - Assert.Null(record.WorldEntity); + RuntimeEntityRecord canonical = CurrentCanonical(runtime, guid); + Assert.Equal((ushort)2, canonical.Generation); + Assert.Null(canonical.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); WorldEntity installed = runtime.MaterializeLiveEntity( guid, 0x01010001u, @@ -1949,7 +1951,7 @@ public sealed class LiveEntityRuntimeTests runtime.RebucketLiveEntity(guid, 0x01020001u); Assert.Same(attached, Assert.Single(spatial.Entities)); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Equal(1, resources.RegisterCount); Assert.Equal(0, resources.UnregisterCount); var destination = Assert.Single( @@ -2082,8 +2084,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0x0202FFFFu, record.CanonicalLandblockId); Assert.True(record.IsSpatiallyProjected); Assert.False(record.IsSpatiallyVisible); - Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Empty(runtime.WorldEntities); + Assert.Same(entity, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Empty(runtime.VisibleRecords); Assert.Equal(1, spatial.PendingLiveEntityCount); Assert.Equal(0, spatial.PendingVisibilityTransitionCount); } @@ -2112,8 +2114,8 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.False(record.IsSpatiallyProjected); Assert.False(record.IsSpatiallyVisible); - Assert.Empty(runtime.MaterializedWorldEntities); - Assert.Empty(runtime.WorldEntities); + Assert.Single(runtime.MaterializedRecords); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); Assert.Equal(0, spatial.PendingLiveEntityCount); Assert.Equal(0, spatial.PendingVisibilityTransitionCount); @@ -2148,8 +2150,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0x01010011u, replacement.FullCellId); Assert.True(replacement.IsSpatiallyProjected); Assert.True(replacement.IsSpatiallyVisible); - Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities)); } @@ -2161,11 +2163,14 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); - runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + WorldEntity original = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; bool replaced = false; - spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) => + spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) => { - if (replaced || visible || edgeGuid != guid) + if (replaced || visible || localEntityId != original.Id) return; replaced = true; Assert.True(runtime.UnregisterLiveEntity( @@ -2183,8 +2188,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0x0101FFFFu, replacement.CanonicalLandblockId); Assert.True(replacement.IsSpatiallyProjected); Assert.True(replacement.IsSpatiallyVisible); - Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Same(replacement.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities)); Assert.Equal(0, spatial.PendingLiveEntityCount); } @@ -2213,8 +2218,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0x01010022u, record.FullCellId); Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyVisible); - Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Same(record.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); } @@ -2260,8 +2265,8 @@ public sealed class LiveEntityRuntimeTests Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyVisible); Assert.Same(pending, record.WorldEntity); - Assert.Same(pending, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Same(pending, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(pending, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Same(pending, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(pending, Assert.Single(spatial.Entities)); } @@ -2300,7 +2305,7 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); Assert.Equal((ushort)2, current.Generation); Assert.Same(replacementEntity, current.WorldEntity); - Assert.Same(replacementEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(replacementEntity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Equal(1, runtime.PendingTeardownCount); Assert.Equal(1, runtime.RetryPendingTeardowns()); Assert.Equal(0, runtime.PendingTeardownCount); @@ -2315,11 +2320,14 @@ public sealed class LiveEntityRuntimeTests spatial.AddLandblock(EmptyLandblock(0x0303FFFFu)); var runtime = new LiveEntityRuntime(spatial, new RecordingResources()); runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u)); - runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)); + WorldEntity original = runtime.MaterializeLiveEntity( + guid, + 0x01010001u, + id => Entity(id, guid))!; bool rebucketed = false; - spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) => + spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) => { - if (rebucketed || visible || edgeGuid != guid) + if (rebucketed || visible || localEntityId != original.Id) return; rebucketed = true; Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u)); @@ -2332,8 +2340,8 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0x0303FFFFu, record.CanonicalLandblockId); Assert.True(record.IsSpatiallyProjected); Assert.True(record.IsSpatiallyVisible); - Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value); - Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value); + Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedRecords).WorldEntity); + Assert.Same(record.WorldEntity, Assert.Single(runtime.VisibleRecords).WorldEntity); Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities)); Assert.Equal(0, spatial.PendingLiveEntityCount); } @@ -2374,7 +2382,8 @@ public sealed class LiveEntityRuntimeTests Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); Assert.Equal((ushort)2, replacement.Generation); Assert.True(replacement.IsSpatiallyVisible); - Assert.True(spatial.IsLiveEntityVisible(guid)); + Assert.True(spatial.IsLiveEntityVisible( + replacement.WorldEntity!.Id)); Assert.Equal(2, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); } @@ -2448,11 +2457,14 @@ public sealed class LiveEntityRuntimeTests error.Flatten().InnerExceptions, exception => exception is InvalidOperationException && exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal)); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)2, current.Generation); - Assert.Null(current.WorldEntity); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Empty(spatial.Entities); - Assert.DoesNotContain(guid, runtime.WorldEntities.Keys); + Assert.DoesNotContain( + runtime.VisibleRecords, + record => record.ServerGuid == guid); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); Assert.False(runtime.TryGetServerGuid(old.Id, out _)); @@ -2479,9 +2491,10 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); Assert.False(outer.LogicalRegistrationCreated); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.WorldEntity); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Empty(spatial.Entities); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); @@ -2509,9 +2522,12 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, outer.Inbound.Disposition); Assert.True(outer.LogicalRegistrationCreated); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement)); + RuntimeEntityRecord replacement = CurrentCanonical(runtime, guid); Assert.Equal((ushort)2, replacement.Generation); - Assert.True(runtime.TryGetRecord(unrelatedGuid, out _)); + Assert.Null(replacement.LocalEntityId); + Assert.Null(CurrentCanonical(runtime, unrelatedGuid).LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); + Assert.False(runtime.TryGetRecord(unrelatedGuid, out _)); Assert.Equal(2, runtime.Count); } @@ -2569,9 +2585,10 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition); Assert.False(outer.LogicalRegistrationCreated); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.WorldEntity); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); } @@ -2600,9 +2617,10 @@ public sealed class LiveEntityRuntimeTests error.Flatten().InnerExceptions, exception => exception is InvalidOperationException && exception.Message == "old incarnation cleanup failed"); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)3, current.Generation); - Assert.Null(current.WorldEntity); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); } @@ -2651,9 +2669,10 @@ public sealed class LiveEntityRuntimeTests runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid))); Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); Assert.Equal((ushort)1, current.Generation); - Assert.Null(current.WorldEntity); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); Assert.Equal(0, runtime.MaterializedCount); @@ -2675,8 +2694,9 @@ public sealed class LiveEntityRuntimeTests runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid))); Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal); - Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current)); - Assert.Null(current.WorldEntity); + RuntimeEntityRecord current = CurrentCanonical(runtime, guid); + Assert.Null(current.LocalEntityId); + Assert.False(runtime.TryGetRecord(guid, out _)); Assert.Equal(1, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); Assert.Equal(0, runtime.MaterializedCount); @@ -2746,7 +2766,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(0, runtime.Count); Assert.Equal(1, runtime.PendingTeardownCount); Assert.Equal(1, runtime.MaterializedCount); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); Assert.Equal(0, spatial.PendingLiveEntityCount); Assert.Equal(1, resources.RegisterCount); @@ -2755,7 +2775,7 @@ public sealed class LiveEntityRuntimeTests runtime.Clear(); Assert.Equal(0, runtime.PendingTeardownCount); Assert.Equal(0, runtime.MaterializedCount); - Assert.Empty(runtime.MaterializedWorldEntities); + Assert.Empty(runtime.MaterializedRecords); Assert.Equal(2, resources.UnregisterCount); } @@ -2779,57 +2799,11 @@ public sealed class LiveEntityRuntimeTests beforeTeardown: () => throw new InvalidOperationException("fixture callback failure"))); Assert.Equal(0, runtime.Count); - Assert.Empty(runtime.WorldEntities); + Assert.Empty(runtime.VisibleRecords); Assert.Empty(spatial.Entities); Assert.Equal(1, resources.UnregisterCount); } - [Fact] - public void IncarnationCleanup_OldTombstoneCannotMutateReplacementGuidState() - { - const uint guid = 0x70000035u; - var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u)); - var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u)); - LiveEntityRecord? current = replacement; - var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current); - object oldHost = new(); - object newHost = new(); - var hosts = new Dictionary { [guid] = newHost }; - bool guidStateCleared = false; - - var plan = new LiveEntityTeardownPlan( - [ - () => cleanup.RunIfNoReplacement(() => guidStateCleared = true), - () => cleanup.RemoveCaptured(hosts, oldHost), - ]); - plan.Advance(); - - Assert.False(guidStateCleared); - Assert.Same(newHost, hosts[guid]); - Assert.True(plan.IsComplete); - } - - [Fact] - public void IncarnationCleanup_RemovesOnlyCapturedOwnerAndResumesAfterReplacementEnds() - { - const uint guid = 0x70000036u; - var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u)); - var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u)); - LiveEntityRecord? current = replacement; - var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current); - object oldHost = new(); - var hosts = new Dictionary { [guid] = oldHost }; - int guidCleanupCount = 0; - - cleanup.RemoveCaptured(hosts, oldHost); - cleanup.RunIfNoReplacement(() => guidCleanupCount++); - current = null; - cleanup.RunIfNoReplacement(() => guidCleanupCount++); - - Assert.Empty(hosts); - Assert.Equal(1, guidCleanupCount); - } - private static LoadedLandblock EmptyLandblock(uint canonicalId) => new(canonicalId, new LandBlock(), Array.Empty()); @@ -2849,6 +2823,34 @@ public sealed class LiveEntityRuntimeTests : null, update => runtime.TryApplyParent(update, out _)); + private static LiveEntityRecord RegisterProjection( + LiveEntityRuntime runtime, + WorldSession.EntitySpawn spawn) + { + LiveEntityRegistrationResult registration = + runtime.RegisterLiveEntity(spawn); + RuntimeEntityRecord canonical = Assert.IsType( + registration.Canonical); + WorldEntity entity = Assert.IsType( + runtime.MaterializeLiveEntity( + canonical, + spawn.Position?.LandblockId ?? 0u, + id => Entity(id, spawn.Guid), + LiveEntityProjectionKind.World, + initializeProjection: null, + out LiveEntityRecord? record)); + Assert.Equal(entity.Id, canonical.LocalEntityId); + return Assert.IsType(record); + } + + private static RuntimeEntityRecord CurrentCanonical( + LiveEntityRuntime runtime, + uint serverGuid) + { + Assert.True(runtime.TryGetCanonical(serverGuid, out RuntimeEntityRecord canonical)); + return canonical; + } + private static WorldEntity Entity(uint id, uint guid) => new() { Id = id, diff --git a/tests/AcDream.App.Tests/World/LiveEntityTestFixture.cs b/tests/AcDream.App.Tests/World/LiveEntityTestFixture.cs new file mode 100644 index 00000000..28a8a597 --- /dev/null +++ b/tests/AcDream.App.Tests/World/LiveEntityTestFixture.cs @@ -0,0 +1,87 @@ +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.World; +using AcDream.Runtime.Entities; + +namespace AcDream.App.World; + +/// +/// Test-only construction seam for exact Runtime identities and App +/// projections. Component fixtures must not manufacture detached +/// instances because that bypasses the same +/// local-ID/incarnation key used by production. +/// +internal static class LiveEntityTestFixture +{ + public static LiveEntityRecord CreateExactProjectionRecord( + WorldSession.EntitySpawn spawn) + { + var directory = new RuntimeEntityDirectory(); + RuntimeEntityRecord canonical = directory.AddActive(spawn); + directory.ClaimLocalId(canonical); + var projections = new LiveEntityProjectionStore(directory); + return projections.AddMaterializing(canonical); + } + + public static LiveEntityRecord RegisterAndMaterializeProjection( + this LiveEntityRuntime runtime, + WorldSession.EntitySpawn spawn, + Func? factory = null, + LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World, + Action? initializeProjection = null) + { + ArgumentNullException.ThrowIfNull(runtime); + + LiveEntityRegistrationResult registration = + runtime.RegisterLiveEntity(spawn); + RuntimeEntityRecord canonical = registration.Canonical + ?? throw new InvalidOperationException( + "The fixture cannot materialize a stale CreateObject."); + if (registration.Projection is { } existing) + return existing; + + WorldEntity? entity = runtime.MaterializeLiveEntity( + canonical, + spawn.Position?.LandblockId ?? canonical.FullCellId, + factory ?? (id => CreateWorldEntity(id, spawn)), + projectionKind, + initializeProjection, + out LiveEntityRecord? record); + if (entity is null || record is null) + { + throw new InvalidOperationException( + "The fixture failed to materialize the exact Runtime incarnation."); + } + + return record; + } + + private static WorldEntity CreateWorldEntity( + uint localEntityId, + WorldSession.EntitySpawn spawn) + { + var position = spawn.Position; + return new WorldEntity + { + Id = localEntityId, + ServerGuid = spawn.Guid, + SourceGfxObjOrSetupId = spawn.SetupTableId ?? 0u, + Position = position is { } placed + ? new Vector3( + placed.PositionX, + placed.PositionY, + placed.PositionZ) + : Vector3.Zero, + Rotation = position is { } oriented + ? new Quaternion( + oriented.RotationX, + oriented.RotationY, + oriented.RotationZ, + oriented.RotationW) + : Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = position?.LandblockId, + EffectCellId = position?.LandblockId, + }; + } +} diff --git a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs index 8190d4d5..63cd9501 100644 --- a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs @@ -1,4 +1,9 @@ using System.Reflection; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Rendering.Wb; using AcDream.App.World; using AcDream.Runtime.Entities; @@ -14,28 +19,118 @@ public sealed class RuntimeEntityOwnershipTests BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException("Missing canonical entity directory."); FieldInfo sidecars = typeof(LiveEntityRuntime).GetField( - "_activeRecords", + "_projections", 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]); + Assert.Equal(typeof(LiveEntityProjectionStore), sidecars.FieldType); } [Fact] - public void AppCompatibilityLookup_IsNotASecondGuidDictionary() + public void AppProjectionOwners_UseExactRuntimeKeysAndNoGuidDictionary() { - FieldInfo compatibilityView = typeof(LiveEntityRuntime).GetField( - "_recordsByGuid", - BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("Missing migration compatibility view."); + FieldInfo[] runtimeFields = typeof(LiveEntityRuntime).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.DoesNotContain(runtimeFields, IsGuidDictionary); - Assert.False(compatibilityView.FieldType.IsGenericType); - Assert.DoesNotContain( - compatibilityView.FieldType.GetInterfaces(), - type => type.IsGenericType - && type.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + FieldInfo[] storeFields = typeof(LiveEntityProjectionStore).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic); + FieldInfo[] exactStores = storeFields + .Where(field => IsDictionaryWithKey(field, typeof(RuntimeEntityKey))) + .ToArray(); + Assert.NotEmpty(exactStores); + Assert.All( + exactStores, + field => Assert.Equal( + typeof(LiveEntityRecord), + field.FieldType.GetGenericArguments()[1])); + Assert.DoesNotContain(storeFields, IsGuidDictionary); + } + + [Fact] + public void MaterializedPresentationWorksets_AreExactKeyed() + { + AssertExactKeyFields( + typeof(LiveEntityPresentationController), + "_readyOwners", + "_suspendedShadowOwners", + "_activePlacementOwners"); + AssertExactKeyFields(typeof(RemoteTeleportController), "_pending"); + AssertExactKeyFields(typeof(LiveRenderProjectionJournal), "_byKey"); + AssertExactKeyFields( + typeof(EntityEffectController), + "_liveProfiles", + "_readyLiveOwners"); + AssertExactKeyFields( + typeof(LiveEntityLightController), + "_trackedOwners", + "_presentOwners"); + AssertExactKeyFields( + typeof(EquippedChildRenderController), + "_attachedByChild", + "_pendingUnparentByChild", + "_pendingOrdinaryRemovalByRoot", + "_pendingDetachedRemovalByChild", + "_pendingReparentRemovalByChild", + "_pendingPoseLossRemovalByChild", + "_pendingOrphanRemovalByChild"); + AssertExactKeyFields(typeof(LiveEntityAnimationScheduler), "_schedules"); + AssertExactKeyFields(typeof(EntitySpawnAdapter), "_ownersByKey"); + AssertExactKeyFields( + typeof(LiveEntityLivenessTracker), + "_deadlines", + "_present"); + AssertExactKeyFields( + typeof(RemoteMovementObservationTracker), + "_lastMove"); + } + + [Fact] + public void AppAssembly_HasNoGuidKeyedLiveEntityRecordDictionary() + { + FieldInfo[] forbidden = typeof(LiveEntityRuntime).Assembly + .GetTypes() + .SelectMany(type => type.GetFields( + BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic)) + .Where(field => + IsDictionaryWithKey(field, typeof(uint)) + && field.FieldType.GetGenericArguments()[1] + == typeof(LiveEntityRecord)) + .ToArray(); + + Assert.Empty(forbidden); + } + + [Fact] + public void ExactOwners_HaveNoRetainedGuidIndex() + { + Type[] exactOwnerTypes = + [ + typeof(LiveEntityProjectionStore), + typeof(LiveEntityPresentationController), + typeof(RemoteTeleportController), + typeof(LiveRenderProjectionJournal), + typeof(LiveEntityLightController), + typeof(EquippedChildRenderController), + typeof(LiveEntityAnimationScheduler), + typeof(EntitySpawnAdapter), + typeof(LiveEntityLivenessTracker), + typeof(RemoteMovementObservationTracker), + ]; + + FieldInfo[] forbidden = exactOwnerTypes + .SelectMany(type => type.GetFields( + BindingFlags.Instance + | BindingFlags.NonPublic)) + .Where(field => + field.Name.Contains("Guid", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + Assert.Empty(forbidden); } [Fact] @@ -73,4 +168,42 @@ public sealed class RuntimeEntityOwnershipTests || ns?.StartsWith("Silk.NET", StringComparison.Ordinal) == true || ns?.StartsWith("ImGuiNET", StringComparison.Ordinal) == true; } + + private static bool IsGuidDictionary(FieldInfo field) => + IsDictionaryWithKey(field, typeof(uint)); + + private static void AssertExactKeyFields( + Type owner, + params string[] fieldNames) + { + foreach (string fieldName in fieldNames) + { + FieldInfo field = owner.GetField( + fieldName, + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException( + $"{owner.Name}.{fieldName} is missing."); + + Assert.True( + IsCollectionWithKey(field, typeof(RuntimeEntityKey)), + $"{owner.Name}.{fieldName} must be keyed by RuntimeEntityKey, " + + $"but was {field.FieldType}."); + } + } + + private static bool IsCollectionWithKey(FieldInfo field, Type keyType) + { + if (!field.FieldType.IsGenericType) + return false; + + Type generic = field.FieldType.GetGenericTypeDefinition(); + return (generic == typeof(Dictionary<,>) + || generic == typeof(HashSet<>)) + && field.FieldType.GetGenericArguments()[0] == keyType; + } + + private static bool IsDictionaryWithKey(FieldInfo field, Type keyType) => + field.FieldType.IsGenericType + && field.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>) + && field.FieldType.GetGenericArguments()[0] == keyType; }