diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 64c2a574..925b890b 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -335,10 +335,10 @@ internal sealed class SessionPlayerCompositionPhase live.WorldAvailability, d.Selection, live.WorldState, - guid => live.LiveEntities.TryGetLocalEntityId( + guid => live.LiveEntities.TryGetProjectionKey( guid, - out uint localEntityId) - ? localEntityId + out AcDream.Runtime.Entities.RuntimeEntityKey key) + ? key : null, content.Audio?.Engine); var compositeWarmupSource = diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 47a72deb..5b2a32cd 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -7,6 +7,7 @@ using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; using AcDream.App.World; using AcDream.Core.World; +using AcDream.Runtime.Entities; namespace AcDream.App.Streaming; @@ -150,13 +151,13 @@ 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(); - // 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 LocalEntityId, bool Visible)> _visibilityTransitions = new(); + // RuntimeEntityKey is the complete materialized identity. Local IDs remain + // stable through retryable teardown, while the incarnation prevents a + // delayed spatial edge from reaching a later reuse of the same local ID. + private readonly Dictionary _liveProjectionByKey = new(); + private readonly Dictionary _visibleLiveProjectionCounts = new(); + private readonly Dictionary _visibilityBeforeMutation = new(); + private readonly Queue<(RuntimeEntityKey Key, bool Visible)> _visibilityTransitions = new(); private readonly List _guidRemovalScratch = new(); private bool _dispatchingVisibilityTransitions; private int _mutationDepth; @@ -167,7 +168,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery public IReadOnlyList Entities => _flatEntities; public ulong FlatViewGeneration => _flatViewGeneration; public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys; - public event Action? LiveProjectionVisibilityChanged; + public event Action? LiveProjectionVisibilityChanged; public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId); public bool IsNearTier(uint landblockId) => @@ -184,13 +185,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery /// presentation gate: destination objects must acquire their render /// resources while portal/login reveal keeps normal world consumers closed. /// - public bool IsLiveEntityProjectionResident(uint localEntityId) => - localEntityId != 0 - && _visibleLiveProjectionCounts.ContainsKey(localEntityId); + public bool IsLiveEntityProjectionResident(RuntimeEntityKey key) => + key.LocalEntityId != 0 + && _visibleLiveProjectionCounts.ContainsKey(key); - public bool IsLiveEntityVisible(uint localEntityId) => + public bool IsLiveEntityVisible(RuntimeEntityKey key) => _availability.IsWorldAvailable - && IsLiveEntityProjectionResident(localEntityId); + && IsLiveEntityProjectionResident(key); /// /// Try to grab the loaded record for a landblock — useful for callers @@ -573,7 +574,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery private readonly record struct ProjectionLocation( uint LandblockId, bool IsLoaded, - int BucketIndex); + int BucketIndex, + RuntimeEntityKey Key); private void EndMutationBatch() { @@ -582,11 +584,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (--_mutationDepth != 0) return; - foreach ((uint localEntityId, bool wasVisible) in _visibilityBeforeMutation) + foreach ((RuntimeEntityKey key, bool wasVisible) in _visibilityBeforeMutation) { - bool isVisible = _visibleLiveProjectionCounts.ContainsKey(localEntityId); + bool isVisible = _visibleLiveProjectionCounts.ContainsKey(key); if (wasVisible != isVisible) - _visibilityTransitions.Enqueue((localEntityId, isVisible)); + _visibilityTransitions.Enqueue((key, isVisible)); } _visibilityBeforeMutation.Clear(); _visibilityCommitCount++; @@ -601,29 +603,35 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery ProbeFlatViewTransitions(); } - private void AdjustVisibleLiveProjection(WorldEntity entity, int delta) + private void AdjustVisibleLiveProjection( + RuntimeEntityKey key, + WorldEntity entity, + int delta) { - uint localEntityId = entity.Id; - if (entity.ServerGuid == 0 || localEntityId == 0 || delta == 0) + if (entity.ServerGuid == 0 || key.LocalEntityId == 0 || delta == 0) return; + if (key.LocalEntityId != entity.Id) + throw new InvalidOperationException( + "A live visibility key must match its WorldEntity local ID."); - _visibleLiveProjectionCounts.TryGetValue(localEntityId, out int priorCount); - if (!_visibilityBeforeMutation.ContainsKey(localEntityId)) - _visibilityBeforeMutation.Add(localEntityId, priorCount > 0); + _visibleLiveProjectionCounts.TryGetValue(key, out int priorCount); + if (!_visibilityBeforeMutation.ContainsKey(key)) + _visibilityBeforeMutation.Add(key, priorCount > 0); int nextCount = checked(priorCount + delta); if (nextCount < 0) throw new InvalidOperationException( $"Live projection visibility count underflow for local " + - $"0x{localEntityId:X8} / server 0x{entity.ServerGuid:X8}."); + $"0x{key.LocalEntityId:X8}/{key.Incarnation} / " + + $"server 0x{entity.ServerGuid:X8}."); if (nextCount == 0) { - _visibleLiveProjectionCounts.Remove(localEntityId); + _visibleLiveProjectionCounts.Remove(key); } else { - _visibleLiveProjectionCounts[localEntityId] = nextCount; + _visibleLiveProjectionCounts[key] = nextCount; } } @@ -669,7 +677,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery WorldEntity entity, uint landblockId, bool isLoaded, - int bucketIndex) + int bucketIndex, + RuntimeEntityKey? requestedKey = null) { if (entity.ServerGuid == 0) return; @@ -677,6 +686,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery bool isNew = !_projectionLocations.TryGetValue( entity, out ProjectionLocation priorLocation); + RuntimeEntityKey key = isNew + ? requestedKey ?? new RuntimeEntityKey(entity.Id, 0) + : priorLocation.Key; + if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id) + { + throw new InvalidOperationException( + "The exact spatial projection key must match the WorldEntity local ID."); + } + if (!isNew && requestedKey is { } requested && requested != key) + { + throw new InvalidOperationException( + "A live spatial projection cannot change exact Runtime identity."); + } if (!isNew && priorLocation.IsLoaded && (!isLoaded || priorLocation.LandblockId != landblockId)) @@ -686,7 +708,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _projectionLocations[entity] = new ProjectionLocation( landblockId, isLoaded, - bucketIndex); + bucketIndex, + key); if (isLoaded && (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId)) { @@ -695,10 +718,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (!isNew) return; - if (!_liveProjectionByLocalId.TryAdd(entity.Id, entity)) + if (!_liveProjectionByKey.TryAdd(key, entity)) { throw new InvalidOperationException( - $"Runtime local projection id 0x{entity.Id:X8} is already spatially owned."); + $"Runtime projection {key} is already spatially owned."); } } @@ -710,17 +733,17 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (location.IsLoaded) RemoveLoadedLiveIndex(location.LandblockId, entity); - if (!_liveProjectionByLocalId.Remove( - entity.Id, + if (!_liveProjectionByKey.Remove( + location.Key, out WorldEntity? indexed)) { throw new InvalidOperationException( - $"Live projection 0x{entity.Id:X8} had no exact local-id index entry."); + $"Live projection {location.Key} had no exact-key index entry."); } if (!ReferenceEquals(indexed, entity)) { throw new InvalidOperationException( - $"Runtime local projection id 0x{entity.Id:X8} resolved a different owner."); + $"Runtime projection {location.Key} resolved a different owner."); } } @@ -754,7 +777,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loadedLiveByLandblock.Remove(landblockId); } - private void AddLoadedProjection(uint landblockId, WorldEntity entity) + private void AddLoadedProjection( + uint landblockId, + WorldEntity entity, + RuntimeEntityKey? requestedKey = null) { LoadedLandblock landblock = _loaded[landblockId]; List entities = MutableEntities(landblock); @@ -763,8 +789,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _animatedIndexByLandblock.Remove(landblockId); InvalidateLandblockRenderViews(entries: true, bounds: false); AddFlatEntity(entity); - SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex); - AdjustVisibleLiveProjection(entity, +1); + if (entity.ServerGuid != 0) + { + SetProjectionLocation( + entity, + landblockId, + isLoaded: true, + bucketIndex, + requestedKey); + AdjustVisibleLiveProjection( + _projectionLocations[entity].Key, + entity, + +1); + } } private void RemoveLoadedProjection(WorldEntity entity) @@ -804,7 +841,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery InvalidateLandblockRenderViews(entries: true, bounds: false); RemoveFlatEntity(entity); RemoveProjectionLocation(entity); - AdjustVisibleLiveProjection(entity, -1); + AdjustVisibleLiveProjection(location.Key, entity, -1); } private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock) @@ -813,8 +850,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery { RemoveFlatEntity(entity); if (entity.ServerGuid != 0) + { + RuntimeEntityKey key = _projectionLocations[entity].Key; RemoveProjectionLocation(entity); - AdjustVisibleLiveProjection(entity, -1); + AdjustVisibleLiveProjection(key, entity, -1); + } } } @@ -826,7 +866,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery AddFlatEntity(entity); if (entity.ServerGuid != 0) SetProjectionLocation(entity, landblock.LandblockId, isLoaded: true, i); - AdjustVisibleLiveProjection(entity, +1); + if (entity.ServerGuid != 0) + { + AdjustVisibleLiveProjection( + _projectionLocations[entity].Key, + entity, + +1); + } } } @@ -1023,8 +1069,25 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery /// the player walks away. /// public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb) + { + ArgumentNullException.ThrowIfNull(entity); + RuntimeEntityKey key = _projectionLocations.TryGetValue( + entity, + out ProjectionLocation existing) + ? existing.Key + : new RuntimeEntityKey(entity.Id, 0); + RebucketLiveEntity(key, entity, newCanonicalLb); + } + + public void RebucketLiveEntity( + RuntimeEntityKey key, + WorldEntity entity, + uint newCanonicalLb) { if (entity.ServerGuid == 0) return; + if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id) + throw new InvalidOperationException( + "The exact rebucket key must match the WorldEntity local ID."); using MutationBatch mutation = BeginMutationBatch(); @@ -1032,7 +1095,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery // Fast path: already drawn in the correct loaded bucket → nothing to do // (avoids per-frame list churn for a settled, stationary entity). - if (_projectionLocations.TryGetValue(entity, out ProjectionLocation current) + bool hasCurrent = _projectionLocations.TryGetValue( + entity, + out ProjectionLocation current); + if (hasCurrent && current.Key != key) + { + throw new InvalidOperationException( + "The live spatial projection is owned by another Runtime incarnation."); + } + if (hasCurrent && current.IsLoaded && current.LandblockId == canonical) { @@ -1059,7 +1130,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery // loaded-to-loaded move and lets reentrant visibility callbacks observe // a state that never exists at the LiveEntityRuntime boundary. RemoveEntityFromAllBuckets(entity); - PlaceLiveEntityProjection(canonical, entity); + PlaceLiveEntityProjection(key, canonical, entity); } /// @@ -1161,10 +1232,17 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery // different landblock by the time the caller reinjects them. var retainedLive = new List(); var retainedLiveSet = new HashSet(ReferenceEqualityComparer.Instance); + var retainedLiveKeys = new Dictionary( + ReferenceEqualityComparer.Instance); void RetainOrRescue(WorldEntity entity, string source) { if (entity.ServerGuid == 0) return; + if (!_projectionLocations.TryGetValue(entity, out ProjectionLocation location)) + { + throw new InvalidOperationException( + $"Live projection 0x{entity.Id:X8} has no exact spatial owner."); + } if (_persistentGuids.Contains(entity.ServerGuid)) { _persistentRescued.Add(entity); @@ -1173,7 +1251,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery return; } if (retainedLiveSet.Add(entity)) + { retainedLive.Add(entity); + retainedLiveKeys.Add(entity, location.Key); + } } // Rescue persistent entities before removal. These get appended @@ -1219,7 +1300,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery RemoveLoadedLandblockFromFlatView(removed); for (int i = 0; i < retainedLive.Count; i++) - SetProjectionLocation(retainedLive[i], canonical, isLoaded: false, i); + { + WorldEntity entity = retainedLive[i]; + SetProjectionLocation( + entity, + canonical, + isLoaded: false, + i, + retainedLiveKeys[entity]); + } return new GpuLandblockRetirement( canonical, @@ -1325,7 +1414,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery // Capture live projections from their small dedicated index rather // than walking every DAT-static entity in the 25x25 world window. var retainedByLandblock = - new Dictionary>(); + new Dictionary>(); var rescued = new HashSet( _persistentRescued, ReferenceEqualityComparer.Instance); @@ -1346,12 +1438,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery if (!retainedByLandblock.TryGetValue( location.LandblockId, - out List<(WorldEntity Entity, int BucketIndex)>? retained)) + out List<( + WorldEntity Entity, + int BucketIndex, + RuntimeEntityKey Key)>? retained)) { retained = []; retainedByLandblock.Add(location.LandblockId, retained); } - retained.Add((entity, location.BucketIndex)); + retained.Add((entity, location.BucketIndex, location.Key)); } int spatialOperationCount = OriginRecenterSpatialOperationCount; @@ -1359,12 +1454,12 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery MutationBatch mutation = BeginMutationBatch(); try { - foreach ((uint localEntityId, int count) in _visibleLiveProjectionCounts) + foreach ((RuntimeEntityKey key, int count) in _visibleLiveProjectionCounts) { if (count > 0 - && !_visibilityBeforeMutation.ContainsKey(localEntityId)) + && !_visibilityBeforeMutation.ContainsKey(key)) { - _visibilityBeforeMutation.Add(localEntityId, true); + _visibilityBeforeMutation.Add(key, true); } } _visibleLiveProjectionCounts.Clear(); @@ -1375,7 +1470,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _flatEntityIndices.Clear(); _projectionLocations.Clear(); _loadedLiveByLandblock.Clear(); - _liveProjectionByLocalId.Clear(); + _liveProjectionByKey.Clear(); _loaded.Clear(); _renderTraversalLandblockSlots.Clear(); @@ -1393,7 +1488,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _landblockBoundsView.Clear(); InvalidateLandblockRenderViews(entries: true, bounds: true); - foreach ((uint id, List<(WorldEntity Entity, int BucketIndex)> retained) in + foreach ((uint id, List<( + WorldEntity Entity, + int BucketIndex, + RuntimeEntityKey Key)> retained) in retainedByLandblock) { retained.Sort(static (left, right) => @@ -1407,7 +1505,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery entity, id, isLoaded: false, - i); + i, + retained[i].Key); } _pendingByLandblock.Add(id, pending); } @@ -1552,7 +1651,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _visibilityBeforeMutation.Clear(); _projectionLocations.Clear(); _loadedLiveByLandblock.Clear(); - _liveProjectionByLocalId.Clear(); + _liveProjectionByKey.Clear(); } /// @@ -1578,10 +1677,22 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery /// /// /// - public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) + public void PlaceLiveEntityProjection(uint landblockId, WorldEntity entity) => + PlaceLiveEntityProjection( + new RuntimeEntityKey(entity.Id, 0), + landblockId, + entity); + + public void PlaceLiveEntityProjection( + RuntimeEntityKey key, + uint landblockId, + WorldEntity entity) { using MutationBatch mutation = BeginMutationBatch(); + if (key.LocalEntityId == 0 || key.LocalEntityId != entity.Id) + throw new InvalidOperationException( + "The exact placement key must match the WorldEntity local ID."); if (_projectionLocations.ContainsKey(entity)) { throw new InvalidOperationException( @@ -1598,7 +1709,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery { // Hot path — append directly to the render-thread-owned mutable // resident bucket and flat view. - AddLoadedProjection(canonicalLandblockId, entity); + AddLoadedProjection(canonicalLandblockId, entity, key); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); return; @@ -1614,7 +1725,12 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery } int bucketIndex = bucket.Count; bucket.Add(entity); - SetProjectionLocation(entity, canonicalLandblockId, isLoaded: false, bucketIndex); + SetProjectionLocation( + entity, + canonicalLandblockId, + isLoaded: false, + bucketIndex, + key); if (probePersistent) EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); } @@ -1837,8 +1953,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery resident.Add(entity); AddFlatEntity(entity); if (entity.ServerGuid != 0) + { SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex); - AdjustVisibleLiveProjection(entity, +1); + AdjustVisibleLiveProjection( + _projectionLocations[entity].Key, + entity, + +1); + } } _animatedIndexByLandblock.Remove(canonical); InvalidateLandblockRenderViews(entries: true, bounds: false); @@ -1873,8 +1994,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery { try { - ((Action)subscribers[i])( - transition.LocalEntityId, + ((Action)subscribers[i])( + transition.Key, transition.Visible); } catch (Exception error) diff --git a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs index 9de0876f..f6b12c79 100644 --- a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs +++ b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs @@ -1,5 +1,6 @@ using AcDream.App.Audio; using AcDream.Core.Selection; +using AcDream.Runtime.Entities; namespace AcDream.App.Streaming; @@ -62,22 +63,22 @@ internal sealed class WorldGenerationQuiescence private readonly WorldGenerationAvailabilityState _availability; private readonly SelectionState _selection; private readonly GpuWorldState _world; - private readonly Func _resolveLocalEntityId; + private readonly Func _resolveProjectionKey; private readonly IWorldAudioQuiescence? _audio; public WorldGenerationQuiescence( WorldGenerationAvailabilityState availability, SelectionState selection, GpuWorldState world, - Func resolveLocalEntityId, + Func resolveProjectionKey, 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)); + _resolveProjectionKey = resolveProjectionKey + ?? throw new ArgumentNullException(nameof(resolveProjectionKey)); _audio = audio; } @@ -89,8 +90,8 @@ internal sealed class WorldGenerationQuiescence uint? selected = firstEdge ? _selection.SelectedObjectId : null; bool clearWorldSelection = selected is uint selectedGuid - && _resolveLocalEntityId(selectedGuid) is uint localEntityId - && _world.IsLiveEntityVisible(localEntityId); + && _resolveProjectionKey(selectedGuid) is RuntimeEntityKey key + && _world.IsLiveEntityVisible(key); _availability.Begin(generation); if (!firstEdge) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index b3567f4f..c3a5e183 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -951,7 +951,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource { try { - _spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId); + _spatial.RebucketLiveEntity( + key, + entity, + spatialCellOrLandblockId); } catch (AggregateException error) { @@ -974,7 +977,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource runtimeNotificationFailure: null); return false; } - bool visible = _spatial.IsLiveEntityProjectionResident(entity.Id); + bool visible = _spatial.IsLiveEntityProjectionResident(key); record.IsSpatiallyVisible = visible; RefreshPresentation(record); if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) @@ -1483,6 +1486,23 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return false; } + internal bool TryGetProjectionKey( + uint serverGuid, + out RuntimeEntityKey key) + { + if (_projections.TryGetCurrent( + serverGuid, + out LiveEntityRecord? record) + && record.ProjectionKey is { } found) + { + key = found; + return true; + } + + key = default; + return false; + } + public bool ClearAnimationRuntime(uint serverGuid) { if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) @@ -2942,25 +2962,26 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource } } - private void OnSpatialVisibilityChanged(uint localEntityId, bool visible) + private void OnSpatialVisibilityChanged(RuntimeEntityKey key, 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(localEntityId) != visible) + if (_spatial.IsLiveEntityProjectionResident(key) != visible) return; - if (!_projections.TryGetByLocalId( - localEntityId, + if (!_projections.TryGet( + key, out LiveEntityRecord? record) || record.WorldEntity is not { } entity - || entity.Id != localEntityId) + || entity.Id != key.LocalEntityId) return; uint serverGuid = record.ServerGuid; bool wasVisible = record.IsSpatiallyVisible; - RuntimeEntityKey key = RequireProjectionKey(record); + if (RequireProjectionKey(record) != key) + return; bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( key, out LiveEntityRecord? indexedRoot) diff --git a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs index 9e882ff1..a965ef1a 100644 --- a/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs +++ b/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs @@ -5,6 +5,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; namespace AcDream.App.Tests.Streaming; @@ -227,15 +228,16 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73600001u); state.PlaceLiveEntityProjection(landblock, entity); - var edges = new List<(uint LocalEntityId, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (localEntityId, visible) => - edges.Add((localEntityId, visible)); + RuntimeEntityKey key = Key(entity); + var edges = new List<(RuntimeEntityKey Key, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (observedKey, visible) => + edges.Add((observedKey, visible)); state.RemoveLandblock(landblock); Assert.Empty(state.Entities); Assert.Equal(1, state.PendingLiveEntityCount); - Assert.False(state.IsLiveEntityVisible(entity.Id)); + Assert.False(state.IsLiveEntityVisible(key)); state.AddLandblock(new LoadedLandblock( landblock, @@ -245,9 +247,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.Id)); + Assert.True(state.IsLiveEntityVisible(key)); Assert.Equal( - [(entity.Id, false), (entity.Id, true)], + [(key, false), (key, true)], edges); state.RemoveLiveEntityProjection(entity); @@ -280,9 +282,9 @@ public sealed class GpuWorldStateVisibilityTests state.MarkPersistent(playerGuid); state.PlaceLiveEntityProjection(secondLandblock, player); state.PlaceLiveEntityProjection(pendingLandblock, pending); - var edges = new List<(uint LocalEntityId, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (localEntityId, visible) => - edges.Add((localEntityId, visible)); + var edges = new List<(RuntimeEntityKey Key, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (key, visible) => + edges.Add((key, visible)); GpuWorldRecenterRetirement result = state.DetachAllForOriginRecenter(); @@ -317,7 +319,7 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(2, state.PendingLiveEntityCount); Assert.Same(player, Assert.Single(state.DrainRescued())); Assert.Equal( - [(remote.Id, false), (player.Id, false)], + [(Key(remote), false), (Key(player), false)], edges); state.AddLandblock(new LoadedLandblock( @@ -379,12 +381,12 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73700001u); int callbacks = 0; - state.LiveProjectionVisibilityChanged += (localEntityId, visible) => + state.LiveProjectionVisibilityChanged += (key, visible) => { callbacks++; - Assert.Equal(entity.Id, localEntityId); + Assert.Equal(entity.Id, key.LocalEntityId); Assert.True(visible); - Assert.True(state.IsLiveEntityVisible(localEntityId)); + Assert.True(state.IsLiveEntityVisible(key)); Assert.Same(entity, Assert.Single(state.Entities)); Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); Assert.Same(entity, Assert.Single(loaded!.Entities)); @@ -415,14 +417,14 @@ public sealed class GpuWorldStateVisibilityTests Array.Empty())); WorldEntity entity = Entity(1u, 0x73800001u); state.PlaceLiveEntityProjection(sourceLandblock, entity); - var edges = new List<(uint LocalEntityId, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (localEntityId, visible) => - edges.Add((localEntityId, visible)); + var edges = new List<(RuntimeEntityKey Key, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (key, visible) => + edges.Add((key, visible)); state.RebucketLiveEntity(entity, targetLandblock); Assert.Empty(edges); - Assert.True(state.IsLiveEntityVisible(entity.Id)); + Assert.True(state.IsLiveEntityVisible(Key(entity))); Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); Assert.Empty(source!.Entities); Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); @@ -430,6 +432,64 @@ public sealed class GpuWorldStateVisibilityTests Assert.Same(entity, Assert.Single(state.Entities)); } + [Fact] + public void LoadedDetachAndReload_PreserveExactIncarnationKey() + { + const uint landblock = 0x0101FFFFu; + var state = new GpuWorldState(); + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + WorldEntity entity = Entity(1u, 0x73800011u); + var exactKey = new RuntimeEntityKey(entity.Id, 37); + state.PlaceLiveEntityProjection(exactKey, landblock, entity); + var edges = new List<(RuntimeEntityKey Key, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (key, visible) => + edges.Add((key, visible)); + + state.DetachLandblock(landblock); + + Assert.Equal([(exactKey, false)], edges); + Assert.False(state.IsLiveEntityVisible(exactKey)); + Assert.Empty(state.Entities); + + state.AddLandblock(new LoadedLandblock( + landblock, + new LandBlock(), + Array.Empty())); + + Assert.Equal([(exactKey, false), (exactKey, true)], edges); + Assert.True(state.IsLiveEntityVisible(exactKey)); + Assert.Same(entity, Assert.Single(state.Entities)); + } + + [Fact] + public void PendingRebucket_WithWrongIncarnation_IsRejectedWithoutMutation() + { + const uint sourceLandblock = 0x0101FFFFu; + const uint targetLandblock = 0x0202FFFFu; + var state = new GpuWorldState(); + WorldEntity entity = Entity(1u, 0x73800012u); + var exactKey = new RuntimeEntityKey(entity.Id, 37); + state.PlaceLiveEntityProjection(exactKey, sourceLandblock, entity); + + Assert.Throws(() => + state.RebucketLiveEntity( + new RuntimeEntityKey(entity.Id, 38), + entity, + targetLandblock)); + + Assert.Equal(1, state.PendingLiveEntityCount); + Assert.False(state.IsLiveEntityProjectionResident(exactKey)); + state.AddLandblock(new LoadedLandblock( + sourceLandblock, + new LandBlock(), + Array.Empty())); + Assert.True(state.IsLiveEntityVisible(exactKey)); + Assert.Same(entity, Assert.Single(state.Entities)); + } + [Fact] public void SameGuidOverlap_TracksExactProjectionVisibilityIndependently() { @@ -444,21 +504,21 @@ public sealed class GpuWorldStateVisibilityTests WorldEntity second = Entity(2u, guid); state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, second); - var edges = new List<(uint LocalEntityId, bool Visible)>(); - state.LiveProjectionVisibilityChanged += (localEntityId, visible) => - edges.Add((localEntityId, visible)); + var edges = new List<(RuntimeEntityKey Key, bool Visible)>(); + state.LiveProjectionVisibilityChanged += (key, visible) => + edges.Add((key, visible)); state.RemoveLiveEntityProjection(first); - Assert.Equal([(first.Id, false)], edges); - Assert.False(state.IsLiveEntityVisible(first.Id)); - Assert.True(state.IsLiveEntityVisible(second.Id)); + Assert.Equal([(Key(first), false)], edges); + Assert.False(state.IsLiveEntityVisible(Key(first))); + Assert.True(state.IsLiveEntityVisible(Key(second))); Assert.Same(second, Assert.Single(state.Entities)); state.RemoveLiveEntityProjection(guid); - Assert.Equal([(first.Id, false), (second.Id, false)], edges); - Assert.False(state.IsLiveEntityVisible(second.Id)); + Assert.Equal([(Key(first), false), (Key(second), false)], edges); + Assert.False(state.IsLiveEntityVisible(Key(second))); Assert.Empty(state.Entities); } @@ -491,11 +551,11 @@ public sealed class GpuWorldStateVisibilityTests var state = new GpuWorldState(); state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, second); - var observed = new List<(uint LocalEntityId, bool Visible)>(); + var observed = new List<(RuntimeEntityKey Key, bool Visible)>(); state.LiveProjectionVisibilityChanged += (_, _) => throw new InvalidOperationException("fixture observer failure"); - state.LiveProjectionVisibilityChanged += (guid, visible) => - observed.Add((guid, visible)); + state.LiveProjectionVisibilityChanged += (key, visible) => + observed.Add((key, visible)); AggregateException error = Assert.Throws(() => state.AddLandblock(new LoadedLandblock( @@ -505,10 +565,10 @@ public sealed class GpuWorldStateVisibilityTests Assert.Equal(2, error.InnerExceptions.Count); Assert.Equal( - [(first.Id, true), (second.Id, true)], - observed.OrderBy(edge => edge.LocalEntityId).ToArray()); - Assert.True(state.IsLiveEntityVisible(first.Id)); - Assert.True(state.IsLiveEntityVisible(second.Id)); + [(Key(first), true), (Key(second), true)], + observed.OrderBy(edge => edge.Key.LocalEntityId).ToArray()); + Assert.True(state.IsLiveEntityVisible(Key(first))); + Assert.True(state.IsLiveEntityVisible(Key(second))); Assert.Equal(0, state.PendingVisibilityTransitionCount); Assert.Equal(2, state.Entities.Count); } @@ -578,13 +638,14 @@ public sealed class GpuWorldStateVisibilityTests id => Entity(id, guid)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); - Assert.False(state.IsLiveEntityVisible(record.WorldEntity!.Id)); - Assert.True(state.IsLiveEntityProjectionResident(record.WorldEntity.Id)); + RuntimeEntityKey projectionKey = record.ProjectionKey!.Value; + Assert.False(state.IsLiveEntityVisible(projectionKey)); + Assert.True(state.IsLiveEntityProjectionResident(projectionKey)); Assert.True(record.IsSpatiallyVisible); Assert.Equal([true], edges); Assert.True(availability.End(7)); - Assert.True(state.IsLiveEntityVisible(record.WorldEntity.Id)); + Assert.True(state.IsLiveEntityVisible(projectionKey)); Assert.True(record.IsSpatiallyVisible); } @@ -670,6 +731,9 @@ public sealed class GpuWorldStateVisibilityTests MeshRefs = Array.Empty(), }; + private static RuntimeEntityKey Key(WorldEntity entity) => + new(entity.Id, 0); + private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) { var position = new CreateObject.ServerPosition( diff --git a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs index 8bb9e57f..7f55ecce 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs @@ -3,6 +3,7 @@ using AcDream.App.Audio; using AcDream.App.Streaming; using AcDream.Core.Selection; using AcDream.Core.World; +using AcDream.Runtime.Entities; using DatReaderWriter.DBObjs; namespace AcDream.App.Tests.Streaming; @@ -23,10 +24,11 @@ public sealed class WorldGenerationQuiescenceTests new LandBlock(), Array.Empty())); world.PlaceLiveEntityProjection(LandblockId, entity); + RuntimeEntityKey key = Key(entity); world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One); var nearby = new List>(); - Assert.True(world.IsLiveEntityVisible(entity.Id)); + Assert.True(world.IsLiveEntityVisible(key)); Assert.Single(world.LandblockEntries); Assert.Single(world.LandblockBounds); @@ -34,7 +36,7 @@ public sealed class WorldGenerationQuiescenceTests world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby); Assert.False(availability.IsWorldAvailable); - Assert.False(world.IsLiveEntityVisible(entity.Id)); + Assert.False(world.IsLiveEntityVisible(key)); Assert.Empty(world.LandblockEntries); Assert.Empty(world.LandblockBounds); Assert.Empty(nearby); @@ -44,7 +46,7 @@ public sealed class WorldGenerationQuiescenceTests Assert.False(availability.End(16)); Assert.False(availability.IsWorldAvailable); Assert.True(availability.End(17)); - Assert.True(world.IsLiveEntityVisible(entity.Id)); + Assert.True(world.IsLiveEntityVisible(key)); Assert.Same(entity, Assert.Single(world.LandblockEntries).Entities.Single()); } @@ -66,7 +68,7 @@ public sealed class WorldGenerationQuiescenceTests availability, selection, world, - guid => guid == ServerGuid ? entity.Id : null, + guid => guid == ServerGuid ? Key(entity) : null, audio); quiescence.Begin(1); @@ -115,6 +117,9 @@ public sealed class WorldGenerationQuiescenceTests MeshRefs = Array.Empty(), }; + private static RuntimeEntityKey Key(WorldEntity entity) => + new(entity.Id, 0); + private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence { public int SuspendCalls { get; private set; } diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 3e0a57f3..e49d1cb4 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -2168,9 +2168,9 @@ public sealed class LiveEntityRuntimeTests 0x01010001u, id => Entity(id, guid))!; bool replaced = false; - spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) => + spatial.LiveProjectionVisibilityChanged += (key, visible) => { - if (replaced || visible || localEntityId != original.Id) + if (replaced || visible || key.LocalEntityId != original.Id) return; replaced = true; Assert.True(runtime.UnregisterLiveEntity( @@ -2325,9 +2325,9 @@ public sealed class LiveEntityRuntimeTests 0x01010001u, id => Entity(id, guid))!; bool rebucketed = false; - spatial.LiveProjectionVisibilityChanged += (localEntityId, visible) => + spatial.LiveProjectionVisibilityChanged += (key, visible) => { - if (rebucketed || visible || localEntityId != original.Id) + if (rebucketed || visible || key.LocalEntityId != original.Id) return; rebucketed = true; Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u)); @@ -2383,7 +2383,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal((ushort)2, replacement.Generation); Assert.True(replacement.IsSpatiallyVisible); Assert.True(spatial.IsLiveEntityVisible( - replacement.WorldEntity!.Id)); + replacement.ProjectionKey!.Value)); Assert.Equal(2, resources.RegisterCount); Assert.Equal(1, resources.UnregisterCount); } diff --git a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs index 63cd9501..24b68fd7 100644 --- a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs @@ -4,6 +4,7 @@ using AcDream.App.Rendering; using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Runtime.Entities; @@ -84,6 +85,11 @@ public sealed class RuntimeEntityOwnershipTests AssertExactKeyFields( typeof(RemoteMovementObservationTracker), "_lastMove"); + AssertExactKeyFields( + typeof(GpuWorldState), + "_liveProjectionByKey", + "_visibleLiveProjectionCounts", + "_visibilityBeforeMutation"); } [Fact]