test(vfx): harden live missile and effect lifetimes

Add deterministic 96-owner lifecycle and renderer-resource stress gates plus an exact twelve-cycle recall/portal ownership gate. Prove zero retained records, projectiles, spatial buckets, script queues, particle/light owners, shadows, pending effects, and mesh references after churn, GUID reuse, deletion, and session reset.

Make logical teardown incarnation-specific and reentrancy-safe with lifetime epochs, atomic resource registration, generation-aware effect/teleport cleanup, projection mutation tokens, and failure-isolated visibility fan-out. Finish canonical spatial transactions before reporting observer failures and never discard superseded cleanup failures.

Synchronize architecture, roadmap, milestones, retail research, divergence bookkeeping, and durable memory. All three independent review tracks are clean; Release build and the full 5,454-pass/5-skip suite are green.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 15:47:00 +02:00
parent 1e98d81448
commit 8d63e5c28a
21 changed files with 2704 additions and 100 deletions

View file

@ -432,6 +432,23 @@ public sealed class EntityEffectControllerTests
Assert.NotEqual(firstLocalId, replacementLocalId);
}
[Fact]
public void DelayedOldEffectTeardown_DoesNotClearReadyReplacementGeneration()
{
var fixture = new Fixture();
WorldEntity old = fixture.ReadyLive(Guid, generation: 1);
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord oldRecord));
WorldEntity replacement = fixture.ReadyLive(Guid, generation: 2);
fixture.Controller.OnLiveEntityUnregistered(oldRecord);
Assert.NotEqual(old.Id, replacement.Id);
Assert.Equal(1, fixture.Controller.ReadyOwnerCount);
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
fixture.Runner.Tick(0.0);
Assert.Equal(replacement.Id, Assert.Single(fixture.Sink.Calls).EntityId);
}
[Fact]
public void ReadyStaticRestOwnerPublishesNetworkSoundOverrideWithoutSetupFallback()
{

View file

@ -0,0 +1,116 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class EntitySpawnAdapterLifetimeTests
{
[Fact]
public void NinetySixLiveOwners_DeleteAndGuidReuse_BalanceEveryMeshReference()
{
const int ownerCount = 96;
const int reuseCount = 32;
const uint guidBase = 0x74000000u;
var meshes = new RefCountingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new NullTextureCache(),
_ => MakeSequencer(),
meshes);
for (int i = 0; i < ownerCount; i++)
{
uint guid = guidBase + (uint)i;
WorldEntity entity = MakeEntity((uint)i + 1u, guid);
entity.MeshRefs =
[
new MeshRef(0x01000001u, Matrix4x4.Identity),
new MeshRef(0x01000001u, Matrix4x4.Identity),
];
entity.ApplyAppearance(
entity.MeshRefs,
entity.PaletteOverride,
[new PartOverride(0, 0x01001000u + (uint)i)]);
Assert.NotNull(adapter.OnCreate(entity));
}
Assert.Equal(ownerCount * 2, meshes.TotalReferenceCount);
for (int i = 0; i < reuseCount; i++)
{
uint guid = guidBase + (uint)i;
adapter.OnRemove(guid);
Assert.Null(adapter.GetState(guid));
WorldEntity replacement = MakeEntity(1000u + (uint)i, guid);
replacement.ApplyAppearance(
replacement.MeshRefs,
replacement.PaletteOverride,
[new PartOverride(0, 0x01002000u + (uint)i)]);
Assert.NotNull(adapter.OnCreate(replacement));
}
for (int i = 0; i < ownerCount; i++)
{
uint guid = guidBase + (uint)i;
adapter.OnRemove(guid);
Assert.Null(adapter.GetState(guid));
}
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Empty(meshes.ReferenceCounts);
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
}
private static WorldEntity MakeEntity(uint id, uint serverGuid) => new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000001u, Matrix4x4.Identity)],
};
private static AnimationSequencer MakeSequencer() =>
new(new Setup(), new MotionTable(), new NullAnimationLoader());
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed class NullTextureCache : ITextureCachePerInstance
{
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride) => 1u;
}
private sealed class RefCountingMeshAdapter : IWbMeshAdapter
{
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public int IncrementCount { get; private set; }
public int DecrementCount { get; private set; }
public int TotalReferenceCount => ReferenceCounts.Values.Sum();
public void IncrementRefCount(ulong id)
{
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
IncrementCount++;
}
public void DecrementRefCount(ulong id)
{
Assert.True(ReferenceCounts.TryGetValue(id, out int count));
Assert.True(count > 0);
if (count == 1)
ReferenceCounts.Remove(id);
else
ReferenceCounts[id] = count - 1;
DecrementCount++;
}
}
}

View file

@ -0,0 +1,139 @@
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 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);
}
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);
}
}

File diff suppressed because it is too large Load diff

View file

@ -62,6 +62,29 @@ public sealed class LiveEntityRuntimeTests
}
}
private sealed class CallbackResources : ILiveEntityResourceLifecycle
{
public Action<WorldEntity>? OnRegister { get; set; }
public Action<WorldEntity>? OnUnregister { get; set; }
public int RegisterCount { get; private set; }
public int UnregisterCount { get; private set; }
public Dictionary<uint, int> UnregisterCountsByGuid { get; } = new();
public void Register(WorldEntity entity)
{
RegisterCount++;
OnRegister?.Invoke(entity);
}
public void Unregister(WorldEntity entity)
{
UnregisterCount++;
UnregisterCountsByGuid[entity.ServerGuid] =
UnregisterCountsByGuid.GetValueOrDefault(entity.ServerGuid) + 1;
OnUnregister?.Invoke(entity);
}
}
[Fact]
public void RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate()
{
@ -955,6 +978,683 @@ public sealed class LiveEntityRuntimeTests
firstLocalEntityId: uint.MaxValue));
}
[Fact]
public void PersistentRescue_DeleteBeforeDrain_ForgetsReferenceAndPersistenceClass()
{
const uint guid = 0x7000002Au;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
spatial.MarkPersistent(guid);
spatial.RemoveLandblock(0x0101FFFFu);
Assert.Equal(1, spatial.PendingRescueCount);
Assert.Equal(1, spatial.PersistentGuidCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Equal(0, spatial.PendingRescueCount);
Assert.Equal(0, spatial.PersistentGuidCount);
Assert.Empty(spatial.DrainRescued());
Assert.Equal(0, runtime.Count);
}
[Fact]
public void SessionClear_ForgetsPersistentClassificationWithoutMaterializedRecord()
{
var spatial = new GpuWorldState();
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
spatial.MarkPersistent(0x7000002Bu);
runtime.Clear();
Assert.Equal(0, spatial.PersistentGuidCount);
Assert.Equal(0, spatial.PendingRescueCount);
}
[Fact]
public void PersistentClassification_SurvivesSameGuidGenerationReplacementUntilSessionReset()
{
const uint guid = 0x7000002Du;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
spatial.MarkPersistent(guid);
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
Assert.Equal(1, spatial.PersistentGuidCount);
spatial.RemoveLandblock(0x0101FFFFu);
Assert.Equal(1, spatial.PendingRescueCount);
runtime.Clear();
Assert.Equal(0, spatial.PersistentGuidCount);
Assert.Equal(0, spatial.PendingRescueCount);
}
[Fact]
public void RebucketObserverFailure_CommitsCanonicalCellAndProjectionBeforeRethrow()
{
const uint guid = 0x70000040u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
spatial.LiveProjectionVisibilityChanged += (_, _) =>
throw new InvalidOperationException("observer failed after spatial commit");
Assert.Throws<AggregateException>(() =>
runtime.RebucketLiveEntity(guid, 0x02020022u));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.Equal(0x02020022u, record.FullCellId);
Assert.Equal(0x0202FFFFu, record.CanonicalLandblockId);
Assert.True(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
Assert.Same(entity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Empty(runtime.WorldEntities);
Assert.Equal(1, spatial.PendingLiveEntityCount);
Assert.Equal(0, spatial.PendingVisibilityTransitionCount);
}
[Fact]
public void WithdrawObserverFailure_CommitsCanonicalWithdrawalBeforeRethrow()
{
const uint guid = 0x70000041u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
runtime.ProjectionVisibilityChanged += (_, visible) =>
{
if (!visible)
throw new InvalidOperationException("observer failed after withdrawal");
};
Assert.Throws<AggregateException>(() =>
runtime.WithdrawLiveEntityProjection(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.False(record.IsSpatiallyProjected);
Assert.False(record.IsSpatiallyVisible);
Assert.Empty(runtime.MaterializedWorldEntities);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(spatial.Entities);
Assert.Equal(0, spatial.PendingLiveEntityCount);
Assert.Equal(0, spatial.PendingVisibilityTransitionCount);
}
[Fact]
public void WithdrawVisibilityCallback_GuidReplacementPreservesNewIncarnationProjection()
{
const uint guid = 0x70000044u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
bool replaced = false;
runtime.ProjectionVisibilityChanged += (edgeRecord, visible) =>
{
if (replaced || visible || edgeRecord.Generation != 1)
return;
replaced = true;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010011u));
runtime.MaterializeLiveEntity(guid, 0x01010011u, id => Entity(id, guid));
};
Assert.False(runtime.WithdrawLiveEntityProjection(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal(0x01010011u, replacement.FullCellId);
Assert.True(replacement.IsSpatiallyProjected);
Assert.True(replacement.IsSpatiallyVisible);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities));
}
[Fact]
public void RebucketSpatialCallback_GuidReplacementPreservesNewIncarnationProjection()
{
const uint guid = 0x70000045u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
bool replaced = false;
spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
{
if (replaced || visible || edgeGuid != guid)
return;
replaced = true;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010012u));
runtime.MaterializeLiveEntity(guid, 0x01010012u, id => Entity(id, guid));
};
Assert.False(runtime.RebucketLiveEntity(guid, 0x02020022u));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal(0x01010012u, replacement.FullCellId);
Assert.Equal(0x0101FFFFu, replacement.CanonicalLandblockId);
Assert.True(replacement.IsSpatiallyProjected);
Assert.True(replacement.IsSpatiallyVisible);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(replacement.WorldEntity, Assert.Single(spatial.Entities));
Assert.Equal(0, spatial.PendingLiveEntityCount);
}
[Fact]
public void WithdrawVisibilityCallback_RebucketSameRecordSupersedesOuterWithdrawal()
{
const uint guid = 0x70000046u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
bool reprojected = false;
runtime.ProjectionVisibilityChanged += (_, visible) =>
{
if (reprojected || visible)
return;
reprojected = true;
Assert.True(runtime.RebucketLiveEntity(guid, 0x01010022u));
};
Assert.False(runtime.WithdrawLiveEntityProjection(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.Equal(0x01010022u, record.FullCellId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
}
[Fact]
public void RebucketSpatialCallback_NewerSameRecordRebucketSupersedesOuterMove()
{
const uint guid = 0x70000047u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
spatial.AddLandblock(EmptyLandblock(0x0303FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
bool rebucketed = false;
spatial.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
{
if (rebucketed || visible || edgeGuid != guid)
return;
rebucketed = true;
Assert.True(runtime.RebucketLiveEntity(guid, 0x03030033u));
};
Assert.False(runtime.RebucketLiveEntity(guid, 0x02020022u));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord record));
Assert.Equal(0x03030033u, record.FullCellId);
Assert.Equal(0x0303FFFFu, record.CanonicalLandblockId);
Assert.True(record.IsSpatiallyProjected);
Assert.True(record.IsSpatiallyVisible);
Assert.Same(record.WorldEntity, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(runtime.WorldEntities).Value);
Assert.Same(record.WorldEntity, Assert.Single(spatial.Entities));
Assert.Equal(0, spatial.PendingLiveEntityCount);
}
[Fact]
public void ReentrantVisibilityGuidReuse_DoesNotApplyQueuedOldEdgeToReplacement()
{
const uint guid = 0x7000002Cu;
var spatial = new GpuWorldState();
var resources = new RecordingResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
var edges = new List<(ushort Generation, bool Visible)>();
bool replaced = false;
runtime.ProjectionVisibilityChanged += (record, visible) =>
{
edges.Add((record.Generation, visible));
if (replaced || record.Generation != 1 || !visible)
return;
replaced = true;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
};
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Assert.Equal([(1, true), (2, true)], edges);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.True(replacement.IsSpatiallyVisible);
Assert.True(spatial.IsLiveEntityVisible(guid));
Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void SessionClearInsideVisibilityCallback_DropsOldQueuedEdgesBeforeReuse()
{
const uint guid = 0x7000002Eu;
var spatial = new GpuWorldState();
var resources = new RecordingResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
var edges = new List<(ushort Generation, bool Visible)>();
bool reset = false;
runtime.ProjectionVisibilityChanged += (record, visible) =>
{
edges.Add((record.Generation, visible));
if (reset || record.Generation != 1 || !visible)
return;
reset = true;
runtime.Clear();
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
};
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Assert.Equal([(1, true), (2, true)], edges);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.True(replacement.IsSpatiallyVisible);
Assert.Equal(0, spatial.PendingVisibilityTransitionCount);
Assert.Equal(2, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void DeleteCallback_CanRegisterNextGenerationButCannotMaterializeBeforeOldTeardown()
{
const uint guid = 0x70000031u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new RecordingResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
WorldEntity old = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
AggregateException error = Assert.Throws<AggregateException>(() =>
runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false,
beforeTeardown: () =>
{
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid));
}));
Assert.Contains(
error.Flatten().InnerExceptions,
exception => exception is InvalidOperationException
&& exception.Message.Contains("active logical-lifetime transition", StringComparison.Ordinal));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)2, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Empty(spatial.Entities);
Assert.DoesNotContain(guid, runtime.WorldEntities.Keys);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.False(runtime.TryGetServerGuid(old.Id, out _));
}
[Fact]
public void GenerationTeardownCallback_NewerReentrantGenerationSupersedesOuterCreate()
{
const uint guid = 0x70000032u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u));
};
LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity(
Spawn(guid, 2, 1, 0x01010001u));
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void GenerationTeardownCallback_UnrelatedRegistrationDoesNotSupersedeOuterCreate()
{
const uint guid = 0x7000003Bu;
const uint unrelatedGuid = 0x7000003Cu;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
runtime.RegisterLiveEntity(Spawn(unrelatedGuid, 1, 1, 0x01010001u));
};
LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity(
Spawn(guid, 2, 1, 0x01010001u));
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, outer.Inbound.Disposition);
Assert.True(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord replacement));
Assert.Equal((ushort)2, replacement.Generation);
Assert.True(runtime.TryGetRecord(unrelatedGuid, out _));
Assert.Equal(2, runtime.Count);
}
[Fact]
public void GenerationTeardownCallback_AcceptedDeletePreventsOuterGenerationResurrection()
{
const uint guid = 0x70000035u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 2),
isLocalPlayer: false));
};
LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity(
Spawn(guid, 2, 1, 0x01010001u));
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.False(runtime.TryGetSnapshot(guid, out _));
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void GenerationTeardownCallback_DeleteThenRegisterCannotAbaOuterEpoch()
{
const uint guid = 0x70000042u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 2),
isLocalPlayer: false));
runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u));
};
LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity(
Spawn(guid, 2, 1, 0x01010001u));
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void GenerationTeardownCallback_NewerGenerationAndFailureIsNeverSilentlyDiscarded()
{
const uint guid = 0x70000043u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
runtime.RegisterLiveEntity(Spawn(guid, 3, 1, 0x01010001u));
throw new InvalidOperationException("old incarnation cleanup failed");
};
AggregateException error = Assert.Throws<AggregateException>(() =>
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u)));
Assert.Contains(
error.Flatten().InnerExceptions,
exception => exception is InvalidOperationException
&& exception.Message == "old incarnation cleanup failed");
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)3, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void GenerationTeardownCallback_SessionClearPreventsOuterGenerationResurrection()
{
const uint guid = 0x70000036u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
runtime.Clear();
};
LiveEntityRegistrationResult outer = runtime.RegisterLiveEntity(
Spawn(guid, 2, 1, 0x01010001u));
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration, outer.Inbound.Disposition);
Assert.False(outer.LogicalRegistrationCreated);
Assert.False(runtime.TryGetRecord(guid, out _));
Assert.False(runtime.TryGetSnapshot(guid, out _));
Assert.Empty(spatial.Entities);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void ResourceRegistrationCallback_CannotReplaceIncarnationAndRollsBackOwner()
{
const uint guid = 0x70000037u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
resources.OnRegister = _ =>
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)));
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)1, current.Generation);
Assert.Null(current.WorldEntity);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(spatial.Entities);
}
[Fact]
public void ResourceRegistrationCallback_CannotClearSessionAndRollsBackOwner()
{
const uint guid = 0x70000038u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
resources.OnRegister = _ => runtime.Clear();
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid)));
Assert.Contains("atomic resource registration", error.Message, StringComparison.Ordinal);
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Null(current.WorldEntity);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(spatial.Entities);
}
[Fact]
public void SessionClear_ReentrantDeleteOfLaterSnapshot_TearsEachRecordDownOnce()
{
const uint firstGuid = 0x70000039u;
const uint secondGuid = 0x7000003Au;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(firstGuid, 1, 1, 0x01010001u));
runtime.RegisterLiveEntity(Spawn(secondGuid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(firstGuid, 0x01010001u, id => Entity(id, firstGuid));
runtime.MaterializeLiveEntity(secondGuid, 0x01010001u, id => Entity(id, secondGuid));
resources.OnUnregister = entity =>
{
if (entity.ServerGuid != firstGuid)
return;
resources.OnUnregister = null;
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(secondGuid, InstanceSequence: 1),
isLocalPlayer: false));
};
runtime.Clear();
Assert.Equal(1, resources.UnregisterCountsByGuid[firstGuid]);
Assert.Equal(1, resources.UnregisterCountsByGuid[secondGuid]);
Assert.Equal(2, resources.UnregisterCount);
Assert.Equal(0, runtime.Count);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(spatial.Entities);
}
[Fact]
public void SessionClear_RejectsRegistrationFromTeardownCallbackWithoutLeakingOwner()
{
const uint oldGuid = 0x70000033u;
const uint callbackGuid = 0x70000034u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new CallbackResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(oldGuid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(oldGuid, 0x01010001u, id => Entity(id, oldGuid));
resources.OnUnregister = _ =>
{
resources.OnUnregister = null;
runtime.RegisterLiveEntity(Spawn(callbackGuid, 1, 1, 0x01010001u));
runtime.MaterializeLiveEntity(
callbackGuid,
0x01010001u,
id => Entity(id, callbackGuid));
};
AggregateException error = Assert.Throws<AggregateException>(() => runtime.Clear());
Assert.Contains(
error.Flatten().InnerExceptions,
exception => exception is InvalidOperationException
&& exception.Message.Contains("while the session lifetime is clearing", StringComparison.Ordinal));
Assert.Equal(0, runtime.Count);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(runtime.MaterializedWorldEntities);
Assert.Empty(spatial.Entities);
Assert.Equal(0, spatial.PendingLiveEntityCount);
Assert.Equal(1, resources.RegisterCount);
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void Delete_CleansLogicalRecordEvenWhenPreTeardownCallbackFails()
{