acdream/tests/AcDream.App.Tests/Streaming/GpuWorldStateVisibilityTests.cs
Erik 91e82c3c68 fix(streaming): prepare quiesced destination entities
Separate loaded spatial residency from the world presentation gate so destination live objects acquire mesh owners behind portal space while drawing and simulation stay quiesced. Prevent unowned CPU-cache hits from recreating stale GPU staging work.

Co-authored-by: Erik Nilsson <erikn@users.noreply.github.com>
2026-07-24 20:21:57 +02:00

567 lines
21 KiB
C#

using System.Numerics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class GpuWorldStateVisibilityTests
{
[Fact]
public void PendingSpawnFlood_DoesNotPublishOrRebuildLoadedView()
{
const uint landblock = 0x0101FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
int edges = 0;
state.LiveProjectionVisibilityChanged += (_, _) => edges++;
long commitsBefore = state.VisibilityCommitCount;
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
state.PlaceLiveEntityProjection(
landblock,
Entity((uint)(i + 1), 0x70000000u + (uint)i));
}
Assert.Empty(state.Entities);
Assert.Equal(count, state.PendingLiveEntityCount);
Assert.Equal(commitsBefore, state.VisibilityCommitCount);
Assert.Equal(0, edges);
}
Assert.Empty(state.Entities);
Assert.Equal(count, state.PendingLiveEntityCount);
Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount);
Assert.Equal(0, edges);
}
[Fact]
public void LoadedSpawnFlood_AppendsInPlaceAndPublishesOneBatch()
{
const uint landblock = 0x0101FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
IReadOnlyList<WorldEntity> residentBucket = loaded!.Entities;
int edges = 0;
state.LiveProjectionVisibilityChanged += (_, visible) =>
{
Assert.True(visible);
edges++;
};
long commitsBefore = state.VisibilityCommitCount;
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
state.PlaceLiveEntityProjection(
landblock,
Entity((uint)(i + 1), 0x71000000u + (uint)i));
}
Assert.Same(residentBucket, loaded.Entities);
Assert.Equal(count, residentBucket.Count);
Assert.Equal(count, state.Entities.Count);
Assert.Equal(0, edges);
Assert.Equal(commitsBefore, state.VisibilityCommitCount);
}
Assert.Equal(count, edges);
Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount);
Assert.Equal(count, state.Entities.Count);
}
[Fact]
public void DenseSameBucketRebucketAndDelete_PreserveEveryProjectionIndex()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
var entities = new WorldEntity[count];
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
entities[i] = Entity((uint)(i + 1), 0x72000000u + (uint)i);
state.PlaceLiveEntityProjection(sourceLandblock, entities[i]);
}
}
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
state.RebucketLiveEntity(entities[i], targetLandblock);
}
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Empty(source!.Entities);
Assert.Equal(count, target!.Entities.Count);
Assert.Equal(count, state.Entities.Count);
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
state.RemoveLiveEntityProjection(entities[i]);
}
Assert.Empty(target.Entities);
Assert.Empty(state.Entities);
}
[Fact]
public void DenseDemotion_CompactsLiveEntriesAndKeepsTheirIndexesValid()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
const int liveCount = 2_000;
var state = new GpuWorldState();
var mixed = new List<WorldEntity>(liveCount * 2);
var live = new List<WorldEntity>(liveCount);
for (int i = 0; i < liveCount; i++)
{
mixed.Add(Entity(0xC1000000u + (uint)i, 0u));
WorldEntity projection = Entity((uint)(i + 1), 0x73000000u + (uint)i);
mixed.Add(projection);
live.Add(projection);
}
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
mixed));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.RemoveEntitiesFromLandblock(sourceLandblock);
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.Equal(liveCount, source!.Entities.Count);
Assert.All(source.Entities, entity => Assert.NotEqual(0u, entity.ServerGuid));
using (state.BeginMutationBatch())
{
foreach (WorldEntity entity in live)
state.RebucketLiveEntity(entity, targetLandblock);
}
Assert.Empty(source.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Equal(liveCount, target!.Entities.Count);
}
[Fact]
public void PendingDemotion_CompactsMixedStaticAndLiveEntriesAndKeepsLiveIndexesValid()
{
const uint pendingLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
var state = new GpuWorldState();
WorldEntity first = Entity(1u, 0x73500001u);
WorldEntity second = Entity(2u, 0x73500002u);
WorldEntity[] mixed =
[
Entity(0xC1000001u, 0u),
first,
Entity(0xC1000002u, 0u),
second,
];
Assert.False(state.AddEntitiesToExistingLandblock(pendingLandblock, mixed));
Assert.Equal(4, state.PendingLiveEntityCount);
state.RemoveEntitiesFromLandblock(pendingLandblock);
Assert.Equal(2, state.PendingLiveEntityCount);
state.AddLandblock(new LoadedLandblock(
pendingLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(state.TryGetLandblock(pendingLandblock, out LoadedLandblock? pending));
Assert.Equal([first, second], pending!.Entities);
state.RebucketLiveEntity(first, targetLandblock);
state.RebucketLiveEntity(second, targetLandblock);
Assert.Empty(pending.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Equal(2, target!.Entities.Count);
Assert.Contains(first, target.Entities);
Assert.Contains(second, target.Entities);
}
[Fact]
public void UnloadToPendingThenReload_PreservesProjectionIdentityAndVisibilityEdges()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73600001u);
state.PlaceLiveEntityProjection(landblock, entity);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible));
state.RemoveLandblock(landblock);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.False(state.IsLiveEntityVisible(entity.ServerGuid));
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded));
Assert.Same(entity, Assert.Single(reloaded!.Entities));
Assert.True(state.IsLiveEntityVisible(entity.ServerGuid));
Assert.Equal(
[(entity.ServerGuid, false), (entity.ServerGuid, true)],
edges);
state.RemoveLiveEntityProjection(entity);
Assert.Empty(state.Entities);
Assert.Empty(reloaded.Entities);
}
[Fact]
public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73700001u);
int callbacks = 0;
state.LiveProjectionVisibilityChanged += (guid, visible) =>
{
callbacks++;
Assert.Equal(entity.ServerGuid, guid);
Assert.True(visible);
Assert.True(state.IsLiveEntityVisible(guid));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
Assert.Same(entity, Assert.Single(loaded!.Entities));
};
using (state.BeginMutationBatch())
{
state.PlaceLiveEntityProjection(landblock, entity);
Assert.Equal(0, callbacks);
}
Assert.Equal(1, callbacks);
}
[Fact]
public void LoadedToLoadedRebucket_EmitsNoVisibilityPulse()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73800001u);
state.PlaceLiveEntityProjection(sourceLandblock, entity);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible));
state.RebucketLiveEntity(entity, targetLandblock);
Assert.Empty(edges);
Assert.True(state.IsLiveEntityVisible(entity.ServerGuid));
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.Empty(source!.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Same(entity, Assert.Single(target!.Entities));
Assert.Same(entity, Assert.Single(state.Entities));
}
[Fact]
public void SameGuidOverlap_ExactRemovalPromotesSecondaryWithoutVisibilityPulse()
{
const uint landblock = 0x0101FFFFu;
const uint guid = 0x73900001u;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity first = Entity(1u, guid);
WorldEntity second = Entity(2u, guid);
state.PlaceLiveEntityProjection(landblock, first);
state.PlaceLiveEntityProjection(landblock, second);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
edges.Add((edgeGuid, visible));
state.RemoveLiveEntityProjection(first);
Assert.Empty(edges);
Assert.True(state.IsLiveEntityVisible(guid));
Assert.Same(second, Assert.Single(state.Entities));
state.RemoveLiveEntityProjection(guid);
Assert.Equal([(guid, false)], edges);
Assert.False(state.IsLiveEntityVisible(guid));
Assert.Empty(state.Entities);
}
[Fact]
public void DuplicateDirectPlacement_IsRejectedBeforeAnyBucketMutation()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x74000001u);
state.PlaceLiveEntityProjection(landblock, entity);
Assert.Throws<InvalidOperationException>(() =>
state.PlaceLiveEntityProjection(landblock, entity));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
Assert.Same(entity, Assert.Single(loaded!.Entities));
}
[Fact]
public void ThrowingObserver_DoesNotStrandLaterSubscribersOrQueuedOwnerEdges()
{
const uint landblock = 0x0101FFFFu;
WorldEntity first = Entity(1u, 0x70000001u);
WorldEntity second = Entity(2u, 0x70000002u);
var state = new GpuWorldState();
state.PlaceLiveEntityProjection(landblock, first);
state.PlaceLiveEntityProjection(landblock, second);
var observed = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (_, _) =>
throw new InvalidOperationException("fixture observer failure");
state.LiveProjectionVisibilityChanged += (guid, visible) =>
observed.Add((guid, visible));
AggregateException error = Assert.Throws<AggregateException>(() =>
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>())));
Assert.Equal(2, error.InnerExceptions.Count);
Assert.Equal(
[(first.ServerGuid, true), (second.ServerGuid, true)],
observed.OrderBy(edge => edge.Guid).ToArray());
Assert.True(state.IsLiveEntityVisible(first.ServerGuid));
Assert.True(state.IsLiveEntityVisible(second.ServerGuid));
Assert.Equal(0, state.PendingVisibilityTransitionCount);
Assert.Equal(2, state.Entities.Count);
}
[Fact]
public void ThrowingRuntimeObserver_DoesNotSkipOtherOwnersOrPresentationSubscribers()
{
const uint landblock = 0x0101FFFFu;
const uint cell = 0x01010001u;
var state = new GpuWorldState();
var runtime = new LiveEntityRuntime(
state,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
WorldSession.EntitySpawn first = Spawn(0x70000011u, cell);
WorldSession.EntitySpawn second = Spawn(0x70000012u, cell);
runtime.RegisterLiveEntity(first);
runtime.RegisterLiveEntity(second);
runtime.MaterializeLiveEntity(first.Guid, cell, id => Entity(id, first.Guid));
runtime.MaterializeLiveEntity(second.Guid, cell, id => Entity(id, second.Guid));
var observed = new List<(uint Guid, bool Visible)>();
runtime.ProjectionVisibilityChanged += (_, _) =>
throw new InvalidOperationException("fixture presentation failure");
runtime.ProjectionVisibilityChanged += (record, visible) =>
observed.Add((record.ServerGuid, visible));
AggregateException error = Assert.Throws<AggregateException>(() =>
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>())));
Assert.Equal(2, error.Flatten().InnerExceptions.Count);
Assert.Equal(
[(first.Guid, true), (second.Guid, true)],
observed.OrderBy(edge => edge.Guid).ToArray());
Assert.True(runtime.TryGetRecord(first.Guid, out LiveEntityRecord firstRecord));
Assert.True(runtime.TryGetRecord(second.Guid, out LiveEntityRecord secondRecord));
Assert.True(firstRecord.IsSpatiallyVisible);
Assert.True(secondRecord.IsSpatiallyVisible);
Assert.Equal(0, state.PendingVisibilityTransitionCount);
}
[Fact]
public void QuiescedDestinationProjection_AcquiresSpatialPresentationBeforeWorldReveal()
{
const uint landblock = 0x0101FFFFu;
const uint cell = 0x01010001u;
const uint guid = 0x70000021u;
var availability = new WorldGenerationAvailabilityState();
availability.Begin(7);
var state = new GpuWorldState(availability: availability);
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
var runtime = new LiveEntityRuntime(
state,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
WorldSession.EntitySpawn spawn = Spawn(guid, cell);
runtime.RegisterLiveEntity(spawn);
var edges = new List<bool>();
runtime.ProjectionVisibilityChanged += (_, visible) => edges.Add(visible);
runtime.MaterializeLiveEntity(
guid,
cell,
id => Entity(id, guid));
Assert.False(state.IsLiveEntityVisible(guid));
Assert.True(state.IsLiveEntityProjectionResident(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.True(record.IsSpatiallyVisible);
Assert.Equal([true], edges);
Assert.True(availability.End(7));
Assert.True(state.IsLiveEntityVisible(guid));
Assert.True(record.IsSpatiallyVisible);
}
[Fact]
public void CopyLiveEntitiesNearLandblock_UsesBoundedNeighborhoodAndSkipsStatics()
{
var centerLive = Entity(1, 0x70000001u);
var neighborLive = Entity(2, 0x70000002u);
var farLive = Entity(3, 0x70000003u);
WorldEntity[] staticEntities = Enumerable.Range(0, 20_000)
.Select(index => Entity((uint)(index + 10), 0u))
.ToArray();
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
0x1010FFFFu,
new LandBlock(),
staticEntities.Append(centerLive).ToArray()));
state.AddLandblock(new LoadedLandblock(
0x1110FFFFu,
new LandBlock(),
new[] { neighborLive }));
state.AddLandblock(new LoadedLandblock(
0x1310FFFFu,
new LandBlock(),
new[] { farLive }));
var destination = new List<KeyValuePair<uint, WorldEntity>>
{
new(0xDEADBEEFu, farLive),
};
state.CopyLiveEntitiesNearLandblock(0x10100001u, 1, destination);
Assert.Equal(2, destination.Count);
Assert.Contains(destination, pair => pair.Key == centerLive.ServerGuid);
Assert.Contains(destination, pair => pair.Key == neighborLive.ServerGuid);
Assert.DoesNotContain(destination, pair => pair.Key == farLive.ServerGuid);
Assert.DoesNotContain(destination, pair => pair.Key == 0u);
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
{
var position = new CreateObject.ServerPosition(
cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)PhysicsStateFlags.ReportCollisions,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: null,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "visibility fixture",
ItemType: null,
MotionState: null,
MotionTableId: null,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}