fix(streaming): preserve exact live projection identity

Carry RuntimeEntityKey through spatial residency, visibility transitions, rebucketing, quiescence, landblock retirement, and origin recentering. This prevents stale incarnation edges from mutating a replacement projection while retaining the static DAT-entity path.

Validated by 104 focused ownership/streaming tests, the Release solution build, and 8,444 complete Release tests with five existing skips.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 22:09:55 +02:00
parent 420e5eea70
commit e937cc36df
8 changed files with 338 additions and 120 deletions

View file

@ -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 =

View file

@ -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<uint, HashSet<WorldEntity>> _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<uint, WorldEntity> _liveProjectionByLocalId = new();
private readonly Dictionary<uint, int> _visibleLiveProjectionCounts = new();
private readonly Dictionary<uint, bool> _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<RuntimeEntityKey, WorldEntity> _liveProjectionByKey = new();
private readonly Dictionary<RuntimeEntityKey, int> _visibleLiveProjectionCounts = new();
private readonly Dictionary<RuntimeEntityKey, bool> _visibilityBeforeMutation = new();
private readonly Queue<(RuntimeEntityKey Key, bool Visible)> _visibilityTransitions = new();
private readonly List<WorldEntity> _guidRemovalScratch = new();
private bool _dispatchingVisibilityTransitions;
private int _mutationDepth;
@ -167,7 +168,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public ulong FlatViewGeneration => _flatViewGeneration;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys;
public event Action<uint, bool>? LiveProjectionVisibilityChanged;
public event Action<RuntimeEntityKey, bool>? 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.
/// </summary>
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);
/// <summary>
/// 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<WorldEntity> 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.
/// </summary>
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);
}
/// <summary>
@ -1161,10 +1232,17 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// different landblock by the time the caller reinjects them.
var retainedLive = new List<WorldEntity>();
var retainedLiveSet = new HashSet<WorldEntity>(ReferenceEqualityComparer.Instance);
var retainedLiveKeys = new Dictionary<WorldEntity, RuntimeEntityKey>(
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<uint, List<(WorldEntity Entity, int BucketIndex)>>();
new Dictionary<uint, List<(
WorldEntity Entity,
int BucketIndex,
RuntimeEntityKey Key)>>();
var rescued = new HashSet<WorldEntity>(
_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();
}
/// <summary>
@ -1578,10 +1677,22 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// </list>
/// </para>
/// </summary>
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<uint, bool>)subscribers[i])(
transition.LocalEntityId,
((Action<RuntimeEntityKey, bool>)subscribers[i])(
transition.Key,
transition.Visible);
}
catch (Exception error)

View file

@ -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<uint, uint?> _resolveLocalEntityId;
private readonly Func<uint, RuntimeEntityKey?> _resolveProjectionKey;
private readonly IWorldAudioQuiescence? _audio;
public WorldGenerationQuiescence(
WorldGenerationAvailabilityState availability,
SelectionState selection,
GpuWorldState world,
Func<uint, uint?> resolveLocalEntityId,
Func<uint, RuntimeEntityKey?> 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)

View file

@ -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)

View file

@ -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>()));
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>()));
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>()));
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>()));
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<WorldEntity>()));
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<InvalidOperationException>(() =>
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<WorldEntity>()));
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<AggregateException>(() =>
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<MeshRef>(),
};
private static RuntimeEntityKey Key(WorldEntity entity) =>
new(entity.Id, 0);
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{
var position = new CreateObject.ServerPosition(

View file

@ -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<WorldEntity>()));
world.PlaceLiveEntityProjection(LandblockId, entity);
RuntimeEntityKey key = Key(entity);
world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One);
var nearby = new List<KeyValuePair<uint, WorldEntity>>();
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<MeshRef>(),
};
private static RuntimeEntityKey Key(WorldEntity entity) =>
new(entity.Id, 0);
private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence
{
public int SuspendCalls { get; private set; }

View file

@ -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);
}

View file

@ -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]