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, live.WorldAvailability,
d.Selection, d.Selection,
live.WorldState, live.WorldState,
guid => live.LiveEntities.TryGetLocalEntityId( guid => live.LiveEntities.TryGetProjectionKey(
guid, guid,
out uint localEntityId) out AcDream.Runtime.Entities.RuntimeEntityKey key)
? localEntityId ? key
: null, : null,
content.Audio?.Engine); content.Audio?.Engine);
var compositeWarmupSource = var compositeWarmupSource =

View file

@ -7,6 +7,7 @@ using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities;
namespace AcDream.App.Streaming; 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 // This index mirrors only loaded live projections and changes at the same
// transaction boundary as _projectionLocations. // transaction boundary as _projectionLocations.
private readonly Dictionary<uint, HashSet<WorldEntity>> _loadedLiveByLandblock = new(); private readonly Dictionary<uint, HashSet<WorldEntity>> _loadedLiveByLandblock = new();
// Runtime-issued local IDs stay unique for the complete materialized // RuntimeEntityKey is the complete materialized identity. Local IDs remain
// lifetime, including retryable teardown. Spatial storage therefore never // stable through retryable teardown, while the incarnation prevents a
// groups graphical owners by reusable server GUID. // delayed spatial edge from reaching a later reuse of the same local ID.
private readonly Dictionary<uint, WorldEntity> _liveProjectionByLocalId = new(); private readonly Dictionary<RuntimeEntityKey, WorldEntity> _liveProjectionByKey = new();
private readonly Dictionary<uint, int> _visibleLiveProjectionCounts = new(); private readonly Dictionary<RuntimeEntityKey, int> _visibleLiveProjectionCounts = new();
private readonly Dictionary<uint, bool> _visibilityBeforeMutation = new(); private readonly Dictionary<RuntimeEntityKey, bool> _visibilityBeforeMutation = new();
private readonly Queue<(uint LocalEntityId, bool Visible)> _visibilityTransitions = new(); private readonly Queue<(RuntimeEntityKey Key, bool Visible)> _visibilityTransitions = new();
private readonly List<WorldEntity> _guidRemovalScratch = new(); private readonly List<WorldEntity> _guidRemovalScratch = new();
private bool _dispatchingVisibilityTransitions; private bool _dispatchingVisibilityTransitions;
private int _mutationDepth; private int _mutationDepth;
@ -167,7 +168,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public IReadOnlyList<WorldEntity> Entities => _flatEntities; public IReadOnlyList<WorldEntity> Entities => _flatEntities;
public ulong FlatViewGeneration => _flatViewGeneration; public ulong FlatViewGeneration => _flatViewGeneration;
public IReadOnlyCollection<uint> LoadedLandblockIds => _loaded.Keys; 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 IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
public bool IsNearTier(uint landblockId) => public bool IsNearTier(uint landblockId) =>
@ -184,13 +185,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// presentation gate: destination objects must acquire their render /// presentation gate: destination objects must acquire their render
/// resources while portal/login reveal keeps normal world consumers closed. /// resources while portal/login reveal keeps normal world consumers closed.
/// </summary> /// </summary>
public bool IsLiveEntityProjectionResident(uint localEntityId) => public bool IsLiveEntityProjectionResident(RuntimeEntityKey key) =>
localEntityId != 0 key.LocalEntityId != 0
&& _visibleLiveProjectionCounts.ContainsKey(localEntityId); && _visibleLiveProjectionCounts.ContainsKey(key);
public bool IsLiveEntityVisible(uint localEntityId) => public bool IsLiveEntityVisible(RuntimeEntityKey key) =>
_availability.IsWorldAvailable _availability.IsWorldAvailable
&& IsLiveEntityProjectionResident(localEntityId); && IsLiveEntityProjectionResident(key);
/// <summary> /// <summary>
/// Try to grab the loaded record for a landblock — useful for callers /// 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( private readonly record struct ProjectionLocation(
uint LandblockId, uint LandblockId,
bool IsLoaded, bool IsLoaded,
int BucketIndex); int BucketIndex,
RuntimeEntityKey Key);
private void EndMutationBatch() private void EndMutationBatch()
{ {
@ -582,11 +584,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (--_mutationDepth != 0) if (--_mutationDepth != 0)
return; 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) if (wasVisible != isVisible)
_visibilityTransitions.Enqueue((localEntityId, isVisible)); _visibilityTransitions.Enqueue((key, isVisible));
} }
_visibilityBeforeMutation.Clear(); _visibilityBeforeMutation.Clear();
_visibilityCommitCount++; _visibilityCommitCount++;
@ -601,29 +603,35 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
ProbeFlatViewTransitions(); 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 || key.LocalEntityId == 0 || delta == 0)
if (entity.ServerGuid == 0 || localEntityId == 0 || delta == 0)
return; 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); _visibleLiveProjectionCounts.TryGetValue(key, out int priorCount);
if (!_visibilityBeforeMutation.ContainsKey(localEntityId)) if (!_visibilityBeforeMutation.ContainsKey(key))
_visibilityBeforeMutation.Add(localEntityId, priorCount > 0); _visibilityBeforeMutation.Add(key, priorCount > 0);
int nextCount = checked(priorCount + delta); int nextCount = checked(priorCount + delta);
if (nextCount < 0) if (nextCount < 0)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Live projection visibility count underflow for local " + $"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) if (nextCount == 0)
{ {
_visibleLiveProjectionCounts.Remove(localEntityId); _visibleLiveProjectionCounts.Remove(key);
} }
else else
{ {
_visibleLiveProjectionCounts[localEntityId] = nextCount; _visibleLiveProjectionCounts[key] = nextCount;
} }
} }
@ -669,7 +677,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
WorldEntity entity, WorldEntity entity,
uint landblockId, uint landblockId,
bool isLoaded, bool isLoaded,
int bucketIndex) int bucketIndex,
RuntimeEntityKey? requestedKey = null)
{ {
if (entity.ServerGuid == 0) if (entity.ServerGuid == 0)
return; return;
@ -677,6 +686,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
bool isNew = !_projectionLocations.TryGetValue( bool isNew = !_projectionLocations.TryGetValue(
entity, entity,
out ProjectionLocation priorLocation); 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 if (!isNew
&& priorLocation.IsLoaded && priorLocation.IsLoaded
&& (!isLoaded || priorLocation.LandblockId != landblockId)) && (!isLoaded || priorLocation.LandblockId != landblockId))
@ -686,7 +708,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_projectionLocations[entity] = new ProjectionLocation( _projectionLocations[entity] = new ProjectionLocation(
landblockId, landblockId,
isLoaded, isLoaded,
bucketIndex); bucketIndex,
key);
if (isLoaded if (isLoaded
&& (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId)) && (isNew || !priorLocation.IsLoaded || priorLocation.LandblockId != landblockId))
{ {
@ -695,10 +718,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!isNew) if (!isNew)
return; return;
if (!_liveProjectionByLocalId.TryAdd(entity.Id, entity)) if (!_liveProjectionByKey.TryAdd(key, entity))
{ {
throw new InvalidOperationException( 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) if (location.IsLoaded)
RemoveLoadedLiveIndex(location.LandblockId, entity); RemoveLoadedLiveIndex(location.LandblockId, entity);
if (!_liveProjectionByLocalId.Remove( if (!_liveProjectionByKey.Remove(
entity.Id, location.Key,
out WorldEntity? indexed)) out WorldEntity? indexed))
{ {
throw new InvalidOperationException( 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)) if (!ReferenceEquals(indexed, entity))
{ {
throw new InvalidOperationException( 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); _loadedLiveByLandblock.Remove(landblockId);
} }
private void AddLoadedProjection(uint landblockId, WorldEntity entity) private void AddLoadedProjection(
uint landblockId,
WorldEntity entity,
RuntimeEntityKey? requestedKey = null)
{ {
LoadedLandblock landblock = _loaded[landblockId]; LoadedLandblock landblock = _loaded[landblockId];
List<WorldEntity> entities = MutableEntities(landblock); List<WorldEntity> entities = MutableEntities(landblock);
@ -763,8 +789,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_animatedIndexByLandblock.Remove(landblockId); _animatedIndexByLandblock.Remove(landblockId);
InvalidateLandblockRenderViews(entries: true, bounds: false); InvalidateLandblockRenderViews(entries: true, bounds: false);
AddFlatEntity(entity); AddFlatEntity(entity);
SetProjectionLocation(entity, landblockId, isLoaded: true, bucketIndex); if (entity.ServerGuid != 0)
AdjustVisibleLiveProjection(entity, +1); {
SetProjectionLocation(
entity,
landblockId,
isLoaded: true,
bucketIndex,
requestedKey);
AdjustVisibleLiveProjection(
_projectionLocations[entity].Key,
entity,
+1);
}
} }
private void RemoveLoadedProjection(WorldEntity entity) private void RemoveLoadedProjection(WorldEntity entity)
@ -804,7 +841,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
InvalidateLandblockRenderViews(entries: true, bounds: false); InvalidateLandblockRenderViews(entries: true, bounds: false);
RemoveFlatEntity(entity); RemoveFlatEntity(entity);
RemoveProjectionLocation(entity); RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(entity, -1); AdjustVisibleLiveProjection(location.Key, entity, -1);
} }
private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock) private void RemoveLoadedLandblockFromFlatView(LoadedLandblock landblock)
@ -813,8 +850,11 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{ {
RemoveFlatEntity(entity); RemoveFlatEntity(entity);
if (entity.ServerGuid != 0) if (entity.ServerGuid != 0)
{
RuntimeEntityKey key = _projectionLocations[entity].Key;
RemoveProjectionLocation(entity); RemoveProjectionLocation(entity);
AdjustVisibleLiveProjection(entity, -1); AdjustVisibleLiveProjection(key, entity, -1);
}
} }
} }
@ -826,7 +866,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
AddFlatEntity(entity); AddFlatEntity(entity);
if (entity.ServerGuid != 0) if (entity.ServerGuid != 0)
SetProjectionLocation(entity, landblock.LandblockId, isLoaded: true, i); 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. /// the player walks away.
/// </summary> /// </summary>
public void RebucketLiveEntity(WorldEntity entity, uint newCanonicalLb) 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 (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(); 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 // Fast path: already drawn in the correct loaded bucket → nothing to do
// (avoids per-frame list churn for a settled, stationary entity). // (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.IsLoaded
&& current.LandblockId == canonical) && current.LandblockId == canonical)
{ {
@ -1059,7 +1130,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// loaded-to-loaded move and lets reentrant visibility callbacks observe // loaded-to-loaded move and lets reentrant visibility callbacks observe
// a state that never exists at the LiveEntityRuntime boundary. // a state that never exists at the LiveEntityRuntime boundary.
RemoveEntityFromAllBuckets(entity); RemoveEntityFromAllBuckets(entity);
PlaceLiveEntityProjection(canonical, entity); PlaceLiveEntityProjection(key, canonical, entity);
} }
/// <summary> /// <summary>
@ -1161,10 +1232,17 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// different landblock by the time the caller reinjects them. // different landblock by the time the caller reinjects them.
var retainedLive = new List<WorldEntity>(); var retainedLive = new List<WorldEntity>();
var retainedLiveSet = new HashSet<WorldEntity>(ReferenceEqualityComparer.Instance); var retainedLiveSet = new HashSet<WorldEntity>(ReferenceEqualityComparer.Instance);
var retainedLiveKeys = new Dictionary<WorldEntity, RuntimeEntityKey>(
ReferenceEqualityComparer.Instance);
void RetainOrRescue(WorldEntity entity, string source) void RetainOrRescue(WorldEntity entity, string source)
{ {
if (entity.ServerGuid == 0) if (entity.ServerGuid == 0)
return; 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)) if (_persistentGuids.Contains(entity.ServerGuid))
{ {
_persistentRescued.Add(entity); _persistentRescued.Add(entity);
@ -1173,7 +1251,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
return; return;
} }
if (retainedLiveSet.Add(entity)) if (retainedLiveSet.Add(entity))
{
retainedLive.Add(entity); retainedLive.Add(entity);
retainedLiveKeys.Add(entity, location.Key);
}
} }
// Rescue persistent entities before removal. These get appended // Rescue persistent entities before removal. These get appended
@ -1219,7 +1300,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
RemoveLoadedLandblockFromFlatView(removed); RemoveLoadedLandblockFromFlatView(removed);
for (int i = 0; i < retainedLive.Count; i++) 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( return new GpuLandblockRetirement(
canonical, canonical,
@ -1325,7 +1414,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// Capture live projections from their small dedicated index rather // Capture live projections from their small dedicated index rather
// than walking every DAT-static entity in the 25x25 world window. // than walking every DAT-static entity in the 25x25 world window.
var retainedByLandblock = 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>( var rescued = new HashSet<WorldEntity>(
_persistentRescued, _persistentRescued,
ReferenceEqualityComparer.Instance); ReferenceEqualityComparer.Instance);
@ -1346,12 +1438,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!retainedByLandblock.TryGetValue( if (!retainedByLandblock.TryGetValue(
location.LandblockId, location.LandblockId,
out List<(WorldEntity Entity, int BucketIndex)>? retained)) out List<(
WorldEntity Entity,
int BucketIndex,
RuntimeEntityKey Key)>? retained))
{ {
retained = []; retained = [];
retainedByLandblock.Add(location.LandblockId, retained); retainedByLandblock.Add(location.LandblockId, retained);
} }
retained.Add((entity, location.BucketIndex)); retained.Add((entity, location.BucketIndex, location.Key));
} }
int spatialOperationCount = OriginRecenterSpatialOperationCount; int spatialOperationCount = OriginRecenterSpatialOperationCount;
@ -1359,12 +1454,12 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
MutationBatch mutation = BeginMutationBatch(); MutationBatch mutation = BeginMutationBatch();
try try
{ {
foreach ((uint localEntityId, int count) in _visibleLiveProjectionCounts) foreach ((RuntimeEntityKey key, int count) in _visibleLiveProjectionCounts)
{ {
if (count > 0 if (count > 0
&& !_visibilityBeforeMutation.ContainsKey(localEntityId)) && !_visibilityBeforeMutation.ContainsKey(key))
{ {
_visibilityBeforeMutation.Add(localEntityId, true); _visibilityBeforeMutation.Add(key, true);
} }
} }
_visibleLiveProjectionCounts.Clear(); _visibleLiveProjectionCounts.Clear();
@ -1375,7 +1470,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_flatEntityIndices.Clear(); _flatEntityIndices.Clear();
_projectionLocations.Clear(); _projectionLocations.Clear();
_loadedLiveByLandblock.Clear(); _loadedLiveByLandblock.Clear();
_liveProjectionByLocalId.Clear(); _liveProjectionByKey.Clear();
_loaded.Clear(); _loaded.Clear();
_renderTraversalLandblockSlots.Clear(); _renderTraversalLandblockSlots.Clear();
@ -1393,7 +1488,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_landblockBoundsView.Clear(); _landblockBoundsView.Clear();
InvalidateLandblockRenderViews(entries: true, bounds: true); 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) retainedByLandblock)
{ {
retained.Sort(static (left, right) => retained.Sort(static (left, right) =>
@ -1407,7 +1505,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
entity, entity,
id, id,
isLoaded: false, isLoaded: false,
i); i,
retained[i].Key);
} }
_pendingByLandblock.Add(id, pending); _pendingByLandblock.Add(id, pending);
} }
@ -1552,7 +1651,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_visibilityBeforeMutation.Clear(); _visibilityBeforeMutation.Clear();
_projectionLocations.Clear(); _projectionLocations.Clear();
_loadedLiveByLandblock.Clear(); _loadedLiveByLandblock.Clear();
_liveProjectionByLocalId.Clear(); _liveProjectionByKey.Clear();
} }
/// <summary> /// <summary>
@ -1578,10 +1677,22 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// </list> /// </list>
/// </para> /// </para>
/// </summary> /// </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(); 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)) if (_projectionLocations.ContainsKey(entity))
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@ -1598,7 +1709,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{ {
// Hot path — append directly to the render-thread-owned mutable // Hot path — append directly to the render-thread-owned mutable
// resident bucket and flat view. // resident bucket and flat view.
AddLoadedProjection(canonicalLandblockId, entity); AddLoadedProjection(canonicalLandblockId, entity, key);
if (probePersistent) if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)");
return; return;
@ -1614,7 +1725,12 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
} }
int bucketIndex = bucket.Count; int bucketIndex = bucket.Count;
bucket.Add(entity); bucket.Add(entity);
SetProjectionLocation(entity, canonicalLandblockId, isLoaded: false, bucketIndex); SetProjectionLocation(
entity,
canonicalLandblockId,
isLoaded: false,
bucketIndex,
key);
if (probePersistent) if (probePersistent)
EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); 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); resident.Add(entity);
AddFlatEntity(entity); AddFlatEntity(entity);
if (entity.ServerGuid != 0) if (entity.ServerGuid != 0)
{
SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex); SetProjectionLocation(entity, canonical, isLoaded: true, bucketIndex);
AdjustVisibleLiveProjection(entity, +1); AdjustVisibleLiveProjection(
_projectionLocations[entity].Key,
entity,
+1);
}
} }
_animatedIndexByLandblock.Remove(canonical); _animatedIndexByLandblock.Remove(canonical);
InvalidateLandblockRenderViews(entries: true, bounds: false); InvalidateLandblockRenderViews(entries: true, bounds: false);
@ -1873,8 +1994,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{ {
try try
{ {
((Action<uint, bool>)subscribers[i])( ((Action<RuntimeEntityKey, bool>)subscribers[i])(
transition.LocalEntityId, transition.Key,
transition.Visible); transition.Visible);
} }
catch (Exception error) catch (Exception error)

View file

@ -1,5 +1,6 @@
using AcDream.App.Audio; using AcDream.App.Audio;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Runtime.Entities;
namespace AcDream.App.Streaming; namespace AcDream.App.Streaming;
@ -62,22 +63,22 @@ internal sealed class WorldGenerationQuiescence
private readonly WorldGenerationAvailabilityState _availability; private readonly WorldGenerationAvailabilityState _availability;
private readonly SelectionState _selection; private readonly SelectionState _selection;
private readonly GpuWorldState _world; private readonly GpuWorldState _world;
private readonly Func<uint, uint?> _resolveLocalEntityId; private readonly Func<uint, RuntimeEntityKey?> _resolveProjectionKey;
private readonly IWorldAudioQuiescence? _audio; private readonly IWorldAudioQuiescence? _audio;
public WorldGenerationQuiescence( public WorldGenerationQuiescence(
WorldGenerationAvailabilityState availability, WorldGenerationAvailabilityState availability,
SelectionState selection, SelectionState selection,
GpuWorldState world, GpuWorldState world,
Func<uint, uint?> resolveLocalEntityId, Func<uint, RuntimeEntityKey?> resolveProjectionKey,
IWorldAudioQuiescence? audio) IWorldAudioQuiescence? audio)
{ {
_availability = availability _availability = availability
?? throw new ArgumentNullException(nameof(availability)); ?? throw new ArgumentNullException(nameof(availability));
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_resolveLocalEntityId = resolveLocalEntityId _resolveProjectionKey = resolveProjectionKey
?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); ?? throw new ArgumentNullException(nameof(resolveProjectionKey));
_audio = audio; _audio = audio;
} }
@ -89,8 +90,8 @@ internal sealed class WorldGenerationQuiescence
uint? selected = firstEdge ? _selection.SelectedObjectId : null; uint? selected = firstEdge ? _selection.SelectedObjectId : null;
bool clearWorldSelection = bool clearWorldSelection =
selected is uint selectedGuid selected is uint selectedGuid
&& _resolveLocalEntityId(selectedGuid) is uint localEntityId && _resolveProjectionKey(selectedGuid) is RuntimeEntityKey key
&& _world.IsLiveEntityVisible(localEntityId); && _world.IsLiveEntityVisible(key);
_availability.Begin(generation); _availability.Begin(generation);
if (!firstEdge) if (!firstEdge)

View file

@ -951,7 +951,10 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
{ {
try try
{ {
_spatial.RebucketLiveEntity(entity, spatialCellOrLandblockId); _spatial.RebucketLiveEntity(
key,
entity,
spatialCellOrLandblockId);
} }
catch (AggregateException error) catch (AggregateException error)
{ {
@ -974,7 +977,7 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
runtimeNotificationFailure: null); runtimeNotificationFailure: null);
return false; return false;
} }
bool visible = _spatial.IsLiveEntityProjectionResident(entity.Id); bool visible = _spatial.IsLiveEntityProjectionResident(key);
record.IsSpatiallyVisible = visible; record.IsSpatiallyVisible = visible;
RefreshPresentation(record); RefreshPresentation(record);
if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu) if ((spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu)
@ -1483,6 +1486,23 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false; 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) public bool ClearAnimationRuntime(uint serverGuid)
{ {
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record) 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 // GpuWorldState serializes re-entrant visibility callbacks. A callback
// may delete an incarnation and materialize the same server GUID before // may delete an incarnation and materialize the same server GUID before
// an older queued edge drains. Apply an edge only while it still // an older queued edge drains. Apply an edge only while it still
// matches current spatial truth; otherwise it belongs to the displaced // matches current spatial truth; otherwise it belongs to the displaced
// projection and must not mutate the replacement generation. // projection and must not mutate the replacement generation.
if (_spatial.IsLiveEntityProjectionResident(localEntityId) != visible) if (_spatial.IsLiveEntityProjectionResident(key) != visible)
return; return;
if (!_projections.TryGetByLocalId( if (!_projections.TryGet(
localEntityId, key,
out LiveEntityRecord? record) out LiveEntityRecord? record)
|| record.WorldEntity is not { } entity || record.WorldEntity is not { } entity
|| entity.Id != localEntityId) || entity.Id != key.LocalEntityId)
return; return;
uint serverGuid = record.ServerGuid; uint serverGuid = record.ServerGuid;
bool wasVisible = record.IsSpatiallyVisible; bool wasVisible = record.IsSpatiallyVisible;
RuntimeEntityKey key = RequireProjectionKey(record); if (RequireProjectionKey(record) != key)
return;
bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue( bool wasOrdinaryRoot = _spatialRootObjects.TryGetValue(
key, key,
out LiveEntityRecord? indexedRoot) out LiveEntityRecord? indexedRoot)

View file

@ -5,6 +5,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming; namespace AcDream.App.Tests.Streaming;
@ -227,15 +228,16 @@ public sealed class GpuWorldStateVisibilityTests
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73600001u); WorldEntity entity = Entity(1u, 0x73600001u);
state.PlaceLiveEntityProjection(landblock, entity); state.PlaceLiveEntityProjection(landblock, entity);
var edges = new List<(uint LocalEntityId, bool Visible)>(); RuntimeEntityKey key = Key(entity);
state.LiveProjectionVisibilityChanged += (localEntityId, visible) => var edges = new List<(RuntimeEntityKey Key, bool Visible)>();
edges.Add((localEntityId, visible)); state.LiveProjectionVisibilityChanged += (observedKey, visible) =>
edges.Add((observedKey, visible));
state.RemoveLandblock(landblock); state.RemoveLandblock(landblock);
Assert.Empty(state.Entities); Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount); Assert.Equal(1, state.PendingLiveEntityCount);
Assert.False(state.IsLiveEntityVisible(entity.Id)); Assert.False(state.IsLiveEntityVisible(key));
state.AddLandblock(new LoadedLandblock( state.AddLandblock(new LoadedLandblock(
landblock, landblock,
@ -245,9 +247,9 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Same(entity, Assert.Single(state.Entities)); Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded)); Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded));
Assert.Same(entity, Assert.Single(reloaded!.Entities)); Assert.Same(entity, Assert.Single(reloaded!.Entities));
Assert.True(state.IsLiveEntityVisible(entity.Id)); Assert.True(state.IsLiveEntityVisible(key));
Assert.Equal( Assert.Equal(
[(entity.Id, false), (entity.Id, true)], [(key, false), (key, true)],
edges); edges);
state.RemoveLiveEntityProjection(entity); state.RemoveLiveEntityProjection(entity);
@ -280,9 +282,9 @@ public sealed class GpuWorldStateVisibilityTests
state.MarkPersistent(playerGuid); state.MarkPersistent(playerGuid);
state.PlaceLiveEntityProjection(secondLandblock, player); state.PlaceLiveEntityProjection(secondLandblock, player);
state.PlaceLiveEntityProjection(pendingLandblock, pending); state.PlaceLiveEntityProjection(pendingLandblock, pending);
var edges = new List<(uint LocalEntityId, bool Visible)>(); var edges = new List<(RuntimeEntityKey Key, bool Visible)>();
state.LiveProjectionVisibilityChanged += (localEntityId, visible) => state.LiveProjectionVisibilityChanged += (key, visible) =>
edges.Add((localEntityId, visible)); edges.Add((key, visible));
GpuWorldRecenterRetirement result = GpuWorldRecenterRetirement result =
state.DetachAllForOriginRecenter(); state.DetachAllForOriginRecenter();
@ -317,7 +319,7 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Equal(2, state.PendingLiveEntityCount); Assert.Equal(2, state.PendingLiveEntityCount);
Assert.Same(player, Assert.Single(state.DrainRescued())); Assert.Same(player, Assert.Single(state.DrainRescued()));
Assert.Equal( Assert.Equal(
[(remote.Id, false), (player.Id, false)], [(Key(remote), false), (Key(player), false)],
edges); edges);
state.AddLandblock(new LoadedLandblock( state.AddLandblock(new LoadedLandblock(
@ -379,12 +381,12 @@ public sealed class GpuWorldStateVisibilityTests
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73700001u); WorldEntity entity = Entity(1u, 0x73700001u);
int callbacks = 0; int callbacks = 0;
state.LiveProjectionVisibilityChanged += (localEntityId, visible) => state.LiveProjectionVisibilityChanged += (key, visible) =>
{ {
callbacks++; callbacks++;
Assert.Equal(entity.Id, localEntityId); Assert.Equal(entity.Id, key.LocalEntityId);
Assert.True(visible); Assert.True(visible);
Assert.True(state.IsLiveEntityVisible(localEntityId)); Assert.True(state.IsLiveEntityVisible(key));
Assert.Same(entity, Assert.Single(state.Entities)); Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded)); Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
Assert.Same(entity, Assert.Single(loaded!.Entities)); Assert.Same(entity, Assert.Single(loaded!.Entities));
@ -415,14 +417,14 @@ public sealed class GpuWorldStateVisibilityTests
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73800001u); WorldEntity entity = Entity(1u, 0x73800001u);
state.PlaceLiveEntityProjection(sourceLandblock, entity); state.PlaceLiveEntityProjection(sourceLandblock, entity);
var edges = new List<(uint LocalEntityId, bool Visible)>(); var edges = new List<(RuntimeEntityKey Key, bool Visible)>();
state.LiveProjectionVisibilityChanged += (localEntityId, visible) => state.LiveProjectionVisibilityChanged += (key, visible) =>
edges.Add((localEntityId, visible)); edges.Add((key, visible));
state.RebucketLiveEntity(entity, targetLandblock); state.RebucketLiveEntity(entity, targetLandblock);
Assert.Empty(edges); Assert.Empty(edges);
Assert.True(state.IsLiveEntityVisible(entity.Id)); Assert.True(state.IsLiveEntityVisible(Key(entity)));
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source)); Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.Empty(source!.Entities); Assert.Empty(source!.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target)); Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
@ -430,6 +432,64 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Same(entity, Assert.Single(state.Entities)); 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] [Fact]
public void SameGuidOverlap_TracksExactProjectionVisibilityIndependently() public void SameGuidOverlap_TracksExactProjectionVisibilityIndependently()
{ {
@ -444,21 +504,21 @@ public sealed class GpuWorldStateVisibilityTests
WorldEntity second = Entity(2u, guid); WorldEntity second = Entity(2u, guid);
state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, first);
state.PlaceLiveEntityProjection(landblock, second); state.PlaceLiveEntityProjection(landblock, second);
var edges = new List<(uint LocalEntityId, bool Visible)>(); var edges = new List<(RuntimeEntityKey Key, bool Visible)>();
state.LiveProjectionVisibilityChanged += (localEntityId, visible) => state.LiveProjectionVisibilityChanged += (key, visible) =>
edges.Add((localEntityId, visible)); edges.Add((key, visible));
state.RemoveLiveEntityProjection(first); state.RemoveLiveEntityProjection(first);
Assert.Equal([(first.Id, false)], edges); Assert.Equal([(Key(first), false)], edges);
Assert.False(state.IsLiveEntityVisible(first.Id)); Assert.False(state.IsLiveEntityVisible(Key(first)));
Assert.True(state.IsLiveEntityVisible(second.Id)); Assert.True(state.IsLiveEntityVisible(Key(second)));
Assert.Same(second, Assert.Single(state.Entities)); Assert.Same(second, Assert.Single(state.Entities));
state.RemoveLiveEntityProjection(guid); state.RemoveLiveEntityProjection(guid);
Assert.Equal([(first.Id, false), (second.Id, false)], edges); Assert.Equal([(Key(first), false), (Key(second), false)], edges);
Assert.False(state.IsLiveEntityVisible(second.Id)); Assert.False(state.IsLiveEntityVisible(Key(second)));
Assert.Empty(state.Entities); Assert.Empty(state.Entities);
} }
@ -491,11 +551,11 @@ public sealed class GpuWorldStateVisibilityTests
var state = new GpuWorldState(); var state = new GpuWorldState();
state.PlaceLiveEntityProjection(landblock, first); state.PlaceLiveEntityProjection(landblock, first);
state.PlaceLiveEntityProjection(landblock, second); state.PlaceLiveEntityProjection(landblock, second);
var observed = new List<(uint LocalEntityId, bool Visible)>(); var observed = new List<(RuntimeEntityKey Key, bool Visible)>();
state.LiveProjectionVisibilityChanged += (_, _) => state.LiveProjectionVisibilityChanged += (_, _) =>
throw new InvalidOperationException("fixture observer failure"); throw new InvalidOperationException("fixture observer failure");
state.LiveProjectionVisibilityChanged += (guid, visible) => state.LiveProjectionVisibilityChanged += (key, visible) =>
observed.Add((guid, visible)); observed.Add((key, visible));
AggregateException error = Assert.Throws<AggregateException>(() => AggregateException error = Assert.Throws<AggregateException>(() =>
state.AddLandblock(new LoadedLandblock( state.AddLandblock(new LoadedLandblock(
@ -505,10 +565,10 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Equal(2, error.InnerExceptions.Count); Assert.Equal(2, error.InnerExceptions.Count);
Assert.Equal( Assert.Equal(
[(first.Id, true), (second.Id, true)], [(Key(first), true), (Key(second), true)],
observed.OrderBy(edge => edge.LocalEntityId).ToArray()); observed.OrderBy(edge => edge.Key.LocalEntityId).ToArray());
Assert.True(state.IsLiveEntityVisible(first.Id)); Assert.True(state.IsLiveEntityVisible(Key(first)));
Assert.True(state.IsLiveEntityVisible(second.Id)); Assert.True(state.IsLiveEntityVisible(Key(second)));
Assert.Equal(0, state.PendingVisibilityTransitionCount); Assert.Equal(0, state.PendingVisibilityTransitionCount);
Assert.Equal(2, state.Entities.Count); Assert.Equal(2, state.Entities.Count);
} }
@ -578,13 +638,14 @@ public sealed class GpuWorldStateVisibilityTests
id => Entity(id, guid)); id => Entity(id, guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record)); Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.False(state.IsLiveEntityVisible(record.WorldEntity!.Id)); RuntimeEntityKey projectionKey = record.ProjectionKey!.Value;
Assert.True(state.IsLiveEntityProjectionResident(record.WorldEntity.Id)); Assert.False(state.IsLiveEntityVisible(projectionKey));
Assert.True(state.IsLiveEntityProjectionResident(projectionKey));
Assert.True(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyVisible);
Assert.Equal([true], edges); Assert.Equal([true], edges);
Assert.True(availability.End(7)); Assert.True(availability.End(7));
Assert.True(state.IsLiveEntityVisible(record.WorldEntity.Id)); Assert.True(state.IsLiveEntityVisible(projectionKey));
Assert.True(record.IsSpatiallyVisible); Assert.True(record.IsSpatiallyVisible);
} }
@ -670,6 +731,9 @@ public sealed class GpuWorldStateVisibilityTests
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
private static RuntimeEntityKey Key(WorldEntity entity) =>
new(entity.Id, 0);
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{ {
var position = new CreateObject.ServerPosition( var position = new CreateObject.ServerPosition(

View file

@ -3,6 +3,7 @@ using AcDream.App.Audio;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.Core.Selection; using AcDream.Core.Selection;
using AcDream.Core.World; using AcDream.Core.World;
using AcDream.Runtime.Entities;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming; namespace AcDream.App.Tests.Streaming;
@ -23,10 +24,11 @@ public sealed class WorldGenerationQuiescenceTests
new LandBlock(), new LandBlock(),
Array.Empty<WorldEntity>())); Array.Empty<WorldEntity>()));
world.PlaceLiveEntityProjection(LandblockId, entity); world.PlaceLiveEntityProjection(LandblockId, entity);
RuntimeEntityKey key = Key(entity);
world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One); world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One);
var nearby = new List<KeyValuePair<uint, WorldEntity>>(); 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.LandblockEntries);
Assert.Single(world.LandblockBounds); Assert.Single(world.LandblockBounds);
@ -34,7 +36,7 @@ public sealed class WorldGenerationQuiescenceTests
world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby); world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby);
Assert.False(availability.IsWorldAvailable); Assert.False(availability.IsWorldAvailable);
Assert.False(world.IsLiveEntityVisible(entity.Id)); Assert.False(world.IsLiveEntityVisible(key));
Assert.Empty(world.LandblockEntries); Assert.Empty(world.LandblockEntries);
Assert.Empty(world.LandblockBounds); Assert.Empty(world.LandblockBounds);
Assert.Empty(nearby); Assert.Empty(nearby);
@ -44,7 +46,7 @@ public sealed class WorldGenerationQuiescenceTests
Assert.False(availability.End(16)); Assert.False(availability.End(16));
Assert.False(availability.IsWorldAvailable); Assert.False(availability.IsWorldAvailable);
Assert.True(availability.End(17)); 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()); Assert.Same(entity, Assert.Single(world.LandblockEntries).Entities.Single());
} }
@ -66,7 +68,7 @@ public sealed class WorldGenerationQuiescenceTests
availability, availability,
selection, selection,
world, world,
guid => guid == ServerGuid ? entity.Id : null, guid => guid == ServerGuid ? Key(entity) : null,
audio); audio);
quiescence.Begin(1); quiescence.Begin(1);
@ -115,6 +117,9 @@ public sealed class WorldGenerationQuiescenceTests
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
}; };
private static RuntimeEntityKey Key(WorldEntity entity) =>
new(entity.Id, 0);
private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence
{ {
public int SuspendCalls { get; private set; } public int SuspendCalls { get; private set; }

View file

@ -2168,9 +2168,9 @@ public sealed class LiveEntityRuntimeTests
0x01010001u, 0x01010001u,
id => Entity(id, guid))!; id => Entity(id, guid))!;
bool replaced = false; 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; return;
replaced = true; replaced = true;
Assert.True(runtime.UnregisterLiveEntity( Assert.True(runtime.UnregisterLiveEntity(
@ -2325,9 +2325,9 @@ public sealed class LiveEntityRuntimeTests
0x01010001u, 0x01010001u,
id => Entity(id, guid))!; id => Entity(id, guid))!;
bool rebucketed = false; 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; return;
rebucketed = true; rebucketed = true;
Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u)); Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u));
@ -2383,7 +2383,7 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal((ushort)2, replacement.Generation); Assert.Equal((ushort)2, replacement.Generation);
Assert.True(replacement.IsSpatiallyVisible); Assert.True(replacement.IsSpatiallyVisible);
Assert.True(spatial.IsLiveEntityVisible( Assert.True(spatial.IsLiveEntityVisible(
replacement.WorldEntity!.Id)); replacement.ProjectionKey!.Value));
Assert.Equal(2, resources.RegisterCount); Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount); Assert.Equal(1, resources.UnregisterCount);
} }

View file

@ -4,6 +4,7 @@ using AcDream.App.Rendering;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Vfx; using AcDream.App.Rendering.Vfx;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
@ -84,6 +85,11 @@ public sealed class RuntimeEntityOwnershipTests
AssertExactKeyFields( AssertExactKeyFields(
typeof(RemoteMovementObservationTracker), typeof(RemoteMovementObservationTracker),
"_lastMove"); "_lastMove");
AssertExactKeyFields(
typeof(GpuWorldState),
"_liveProjectionByKey",
"_visibleLiveProjectionCounts",
"_visibilityBeforeMutation");
} }
[Fact] [Fact]