fix(rendering): bound portal resource lifetime

Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-18 21:35:16 +02:00
parent 3971997689
commit 749e8ceeb1
225 changed files with 29107 additions and 3914 deletions

View file

@ -0,0 +1,109 @@
using AcDream.App.World;
using AcDream.Core.World;
namespace AcDream.App.Tests.World;
public sealed class CompositeLiveEntityResourceLifecycleTests
{
[Fact]
public void LateReleaseFailureDoesNotReplayEarlierCommittedOwner()
{
var entity = Entity();
int firstReleases = 0;
int secondReleases = 0;
var lifecycle = new CompositeLiveEntityResourceLifecycle(
new(_ => { }, _ =>
{
firstReleases++;
if (firstReleases == 1)
throw new InvalidOperationException("first failed");
}),
new(_ => { }, _ => secondReleases++));
lifecycle.Register(entity);
Assert.Throws<AggregateException>(() => lifecycle.Unregister(entity));
Assert.Equal(1, secondReleases);
lifecycle.Unregister(entity);
Assert.Equal(2, firstReleases);
Assert.Equal(1, secondReleases);
}
[Fact]
public void RegistrationFailureAttemptsEveryEnteredOwnerRollback()
{
var entity = Entity();
var releases = new List<int>();
var lifecycle = new CompositeLiveEntityResourceLifecycle(
new(_ => { }, _ => releases.Add(1)),
new(
_ => throw new InvalidOperationException("second registration failed"),
_ => releases.Add(2)),
new(_ => throw new InvalidOperationException("must not enter"), _ => releases.Add(3)));
Assert.Throws<InvalidOperationException>(() => lifecycle.Register(entity));
Assert.Equal([2, 1], releases);
lifecycle.Unregister(entity);
Assert.Equal([2, 1], releases);
}
[Fact]
public void RegisterCallbackUnregistersSameEntityWithoutOrphaningLaterOwners()
{
var entity = Entity();
CompositeLiveEntityResourceLifecycle? lifecycle = null;
int firstRegisters = 0;
int firstReleases = 0;
int laterRegisters = 0;
lifecycle = new CompositeLiveEntityResourceLifecycle(
new(
current =>
{
firstRegisters++;
lifecycle!.Unregister(current);
},
_ => firstReleases++),
new(_ => laterRegisters++, _ => { }));
lifecycle.Register(entity);
lifecycle.Unregister(entity);
Assert.Equal(1, firstRegisters);
Assert.Equal(1, firstReleases);
Assert.Equal(0, laterRegisters);
}
[Fact]
public void UnregisterCallbackReentryDoesNotRepeatActiveOwnerRelease()
{
var entity = Entity();
CompositeLiveEntityResourceLifecycle? lifecycle = null;
int releases = 0;
lifecycle = new CompositeLiveEntityResourceLifecycle(
new CompositeLiveEntityResourceLifecycle.Owner(
_ => { },
current =>
{
releases++;
lifecycle!.Unregister(current);
}));
lifecycle.Register(entity);
lifecycle.Unregister(entity);
lifecycle.Unregister(entity);
Assert.Equal(1, releases);
}
private static WorldEntity Entity() => new()
{
Id = 1,
ServerGuid = 0x70000001u,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
}

View file

@ -1,5 +1,6 @@
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
@ -34,6 +35,11 @@ public sealed class LiveEntityRuntimeTests
public PhysicsBody Body { get; } = new();
}
private sealed class ProjectileRuntime(PhysicsBody body) : ILiveEntityProjectileRuntime
{
public PhysicsBody Body { get; } = body;
}
private sealed class FailingRegisterResources : ILiveEntityResourceLifecycle
{
public int RegisterCount { get; private set; }
@ -48,6 +54,34 @@ public sealed class LiveEntityRuntimeTests
public void Unregister(WorldEntity entity) => UnregisterCount++;
}
private sealed class FailingRegisterAndRollbackOnceResources : ILiveEntityResourceLifecycle
{
private bool _registerFailurePending = true;
private bool _unregisterFailurePending = true;
public int RegisterCount { get; private set; }
public int UnregisterCount { get; private set; }
public void Register(WorldEntity entity)
{
RegisterCount++;
if (_registerFailurePending)
{
_registerFailurePending = false;
throw new InvalidOperationException("partial registration failure");
}
}
public void Unregister(WorldEntity entity)
{
UnregisterCount++;
if (_unregisterFailurePending)
{
_unregisterFailurePending = false;
throw new InvalidOperationException("rollback failure");
}
}
}
private sealed class FailingUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle
{
public int UnregisterCount { get; private set; }
@ -62,6 +96,38 @@ public sealed class LiveEntityRuntimeTests
}
}
private sealed class FailingOnceUnregisterResources(uint failingGuid) : ILiveEntityResourceLifecycle
{
private bool _failurePending = true;
public int UnregisterCount { get; private set; }
public void Register(WorldEntity entity) { }
public void Unregister(WorldEntity entity)
{
UnregisterCount++;
if (entity.ServerGuid == failingGuid && _failurePending)
{
_failurePending = false;
throw new InvalidOperationException("fixture one-shot unregister failure");
}
}
}
private sealed class CallbackTextureLifetime : IEntityTextureLifetime
{
public Action? OnRelease { get; set; }
public int ReleaseCount { get; private set; }
public void ReleaseOwner(uint localEntityId)
{
ReleaseCount++;
OnRelease?.Invoke();
}
}
private sealed class NullAnimationLoader : IAnimationLoader
{
public Animation? LoadAnimation(uint id) => null;
}
private sealed class CallbackResources : ILiveEntityResourceLifecycle
{
public Action<WorldEntity>? OnRegister { get; set; }
@ -123,6 +189,167 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(new[] { false, true }, visibilityEdges);
}
[Fact]
public void SpatialComponentWorksets_FollowLoadedProjectionWhileLogicalOwnersSurvive()
{
const uint guid = 0x70000070u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
var animation = new AnimationRuntime(entity);
var remote = new RemoteMotionRuntime();
var projectile = new ProjectileRuntime(remote.Body);
runtime.SetAnimationRuntime(guid, animation);
runtime.SetRemoteMotionRuntime(guid, remote);
runtime.SetProjectileRuntime(guid, projectile);
Assert.Same(animation, Assert.Single(runtime.SpatialAnimationRuntimes).Value);
Assert.Same(remote, Assert.Single(runtime.SpatialRemoteMotionRuntimes).Value);
Assert.Same(projectile, Assert.Single(runtime.SpatialProjectileRuntimes).Value);
// Hidden suppresses presentation, not the live CPhysicsObj workset.
Assert.True(runtime.TryApplyState(new SetState.Parsed(
guid,
(uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.IgnoreCollisions),
1,
2), out _));
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Single(runtime.SpatialRemoteMotionRuntimes);
Assert.Single(runtime.SpatialProjectileRuntimes);
// A pending projection retains every logical component but disappears
// from all per-frame worksets.
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
Assert.Empty(runtime.SpatialAnimationRuntimes);
Assert.Empty(runtime.SpatialRemoteMotionRuntimes);
Assert.Empty(runtime.SpatialProjectileRuntimes);
Assert.True(runtime.TryGetAnimationRuntime(entity.Id, out var retainedAnimation));
Assert.Same(animation, retainedAnimation);
Assert.True(runtime.TryGetRemoteMotionRuntime(guid, out var retainedRemote));
Assert.Same(remote, retainedRemote);
Assert.True(runtime.TryGetProjectileRuntime(guid, out var retainedProjectile));
Assert.Same(projectile, retainedProjectile);
spatial.AddLandblock(EmptyLandblock(0x0202FFFFu));
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Single(runtime.SpatialRemoteMotionRuntimes);
Assert.Single(runtime.SpatialProjectileRuntimes);
Assert.True(runtime.WithdrawLiveEntityProjection(guid));
Assert.Empty(runtime.SpatialAnimationRuntimes);
Assert.Empty(runtime.SpatialRemoteMotionRuntimes);
Assert.Empty(runtime.SpatialProjectileRuntimes);
}
[Fact]
public void AnimationView_EnumeratesSpatialOwnersButRetainsLogicalLookupAndRemoval()
{
const uint guid = 0x70000071u;
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))!;
var animation = new AnimationRuntime(entity);
runtime.SetAnimationRuntime(guid, animation);
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime);
Assert.Equal(1, view.Count);
Assert.Equal(entity.Id, Assert.Single(view.Keys));
Assert.Same(animation, Assert.Single(view).Value);
Assert.True(runtime.RebucketLiveEntity(guid, 0x02020001u));
Assert.Equal(0, view.Count);
Assert.Empty(view.Keys);
Assert.Empty(view);
Assert.True(view.TryGetValue(entity.Id, out AnimationRuntime retained));
Assert.Same(animation, retained);
Assert.True(view.Remove(entity.Id));
Assert.False(view.TryGetValue(entity.Id, out _));
Assert.Empty(runtime.AnimationRuntimes);
Assert.Empty(runtime.SpatialAnimationRuntimes);
}
[Fact]
public void AnimationView_RebucketCallbackSkipsDisplacedSnapshotOwner()
{
const uint firstGuid = 0x70000073u;
const uint secondGuid = 0x70000074u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
foreach (uint guid in new[] { firstGuid, secondGuid })
{
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
WorldEntity entity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(entity));
}
var view = new LiveEntityAnimationRuntimeView<AnimationRuntime>(() => runtime);
var visited = new List<uint>();
foreach (KeyValuePair<uint, AnimationRuntime> pair in view)
{
uint guid = pair.Value.Entity.ServerGuid;
visited.Add(guid);
uint other = guid == firstGuid ? secondGuid : firstGuid;
Assert.True(runtime.RebucketLiveEntity(other, 0x02020001u));
}
Assert.Single(visited);
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Equal(2, runtime.AnimationRuntimes.Count);
}
[Fact]
public void SpatialComponentWorksets_IsolateGuidReuseAcrossVisibilityCallbacks()
{
const uint guid = 0x70000072u;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var runtime = new LiveEntityRuntime(spatial, new RecordingResources());
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
WorldEntity oldEntity = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
var oldAnimation = new AnimationRuntime(oldEntity);
runtime.SetAnimationRuntime(guid, oldAnimation);
runtime.ProjectionVisibilityChanged += (record, visible) =>
{
if (visible || record.Generation != 1)
return;
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010002u));
WorldEntity replacement = runtime.MaterializeLiveEntity(
guid,
0x01010002u,
id => Entity(id, guid))!;
runtime.SetAnimationRuntime(guid, new AnimationRuntime(replacement));
};
Assert.False(runtime.WithdrawLiveEntityProjection(guid));
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Equal((ushort)2, current.Generation);
KeyValuePair<uint, ILiveEntityAnimationRuntime> indexed =
Assert.Single(runtime.SpatialAnimationRuntimes);
Assert.Same(current.AnimationRuntime, indexed.Value);
Assert.NotSame(oldAnimation, indexed.Value);
}
[Fact]
public void EffectProfile_SurvivesRebucketAndClearsWithLogicalTeardown()
{
@ -843,6 +1070,100 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void RegistrationRollbackFailureRetainsTombstoneUntilRetryConverges()
{
const uint guid = 0x7000005Au;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new FailingRegisterAndRollbackOnceResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
Assert.Throws<AggregateException>(() => runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid)));
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
Assert.False(runtime.TryGetRecord(guid, out _));
spatial.MarkPersistent(guid);
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Equal(2, resources.UnregisterCount);
Assert.Equal(1, spatial.PersistentGuidCount);
LiveEntityRegistrationResult recovered = runtime.RegisterLiveEntity(
Spawn(guid, 1, 1, 0x01010001u));
Assert.True(recovered.LogicalRegistrationCreated);
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
spatial.RemoveLandblock(0x0101FFFFu);
Assert.Equal(1, spatial.PendingRescueCount);
Assert.Equal(1, spatial.PersistentGuidCount);
}
[Fact]
public void RegistrationRollbackTombstoneIsIsolatedFromSameGuidReplacement()
{
const uint guid = 0x7000005Bu;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new FailingRegisterAndRollbackOnceResources();
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.RegisterLiveEntity(Spawn(guid, 1, 1, 0x01010001u));
Assert.Throws<AggregateException>(() => runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid)));
runtime.RegisterLiveEntity(Spawn(guid, 2, 1, 0x01010001u));
WorldEntity replacement = runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid))!;
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.True(runtime.TryGetRecord(guid, out LiveEntityRecord current));
Assert.Same(replacement, current.WorldEntity);
Assert.Same(replacement, Assert.Single(runtime.MaterializedWorldEntities).Value);
Assert.Equal(0, runtime.PendingTeardownCount);
}
[Fact]
public void SameGenerationCreateCannotRecoverUntilRegistrationTombstoneConverges()
{
const uint guid = 0x7000005Cu;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new FailingRegisterAndRollbackOnceResources();
var runtime = new LiveEntityRuntime(spatial, resources);
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
Assert.Throws<AggregateException>(() => runtime.MaterializeLiveEntity(
guid,
0x01010001u,
id => Entity(id, guid)));
LiveEntityRegistrationResult retransmit = runtime.RegisterLiveEntity(spawn);
Assert.False(retransmit.LogicalRegistrationCreated);
Assert.Null(retransmit.Record);
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.True(runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(guid, InstanceSequence: 1),
isLocalPlayer: false));
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.False(runtime.TryGetSnapshot(guid, out _));
}
[Fact]
public void SessionClear_AttemptsEveryTeardownAndClearsIdentityWhenOneResourceFails()
{
@ -864,11 +1185,107 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(2, resources.UnregisterCount);
Assert.Equal(0, runtime.Count);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
Assert.Empty(runtime.WorldEntities);
Assert.Empty(spatial.Entities);
}
[Fact]
public void Delete_UnregisterFailureRetainsTombstoneAndSameDeleteRetriesCleanup()
{
const uint guid = 0x7000004Au;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new FailingOnceUnregisterResources(guid);
var runtime = new LiveEntityRuntime(spatial, resources);
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1);
Assert.Throws<AggregateException>(
() => runtime.UnregisterLiveEntity(delete, isLocalPlayer: false));
Assert.Equal(0, runtime.Count);
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
Assert.Equal(1, resources.UnregisterCount);
Assert.True(runtime.UnregisterLiveEntity(delete, isLocalPlayer: false));
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Equal(2, resources.UnregisterCount);
}
[Fact]
public void SessionClear_OneShotUnregisterFailure_SecondClearConverges()
{
const uint guid = 0x7000004Bu;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var resources = new FailingOnceUnregisterResources(guid);
var runtime = new LiveEntityRuntime(spatial, resources);
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
runtime.MaterializeLiveEntity(guid, 0x01010001u, id => Entity(id, guid));
Assert.Throws<AggregateException>(() => runtime.Clear());
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, runtime.MaterializedCount);
runtime.Clear();
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(spatial.Entities);
Assert.Equal(2, resources.UnregisterCount);
}
[Fact]
public void ReentrantDeleteDuringPresentationSuspend_IsQueuedAndNextUpdateCompletesIt()
{
const uint guid = 0x7000004Cu;
var spatial = new GpuWorldState();
spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
var textures = new CallbackTextureLifetime();
var adapter = new EntitySpawnAdapter(
textures,
_ => new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()));
var resources = new DelegateLiveEntityResourceLifecycle(
entity => adapter.OnCreate(entity),
entity =>
{
if (!adapter.OnRemove(entity))
throw new InvalidOperationException("presentation owner was not registered");
});
var runtime = new LiveEntityRuntime(spatial, resources);
runtime.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
adapter.SetPresentationResident(entity, visible);
};
WorldSession.EntitySpawn spawn = Spawn(guid, 1, 1, 0x01010001u);
runtime.RegisterLiveEntity(spawn);
WorldEntity entity = runtime.MaterializeLiveEntity(
guid, 0x01010001u, id => Entity(id, guid))!;
var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1);
textures.OnRelease = () =>
{
textures.OnRelease = null;
runtime.UnregisterLiveEntity(delete, isLocalPlayer: false);
};
Assert.Throws<AggregateException>(
() => adapter.SetPresentationResident(entity, resident: false));
Assert.Equal(1, runtime.PendingTeardownCount);
Assert.NotNull(adapter.GetState(guid));
Assert.Equal(1, runtime.RetryPendingTeardowns());
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Null(adapter.GetState(guid));
Assert.Equal(0, runtime.MaterializedCount);
}
[Fact]
public void GenerationReplacement_InstallsNewRecordBeforeReportingOldCleanupFailure()
{
@ -1646,13 +2063,19 @@ public sealed class LiveEntityRuntimeTests
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.Equal(1, runtime.PendingTeardownCount);
Assert.Equal(1, 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);
runtime.Clear();
Assert.Equal(0, runtime.PendingTeardownCount);
Assert.Equal(0, runtime.MaterializedCount);
Assert.Empty(runtime.MaterializedWorldEntities);
Assert.Equal(2, resources.UnregisterCount);
}
[Fact]
@ -1680,6 +2103,52 @@ public sealed class LiveEntityRuntimeTests
Assert.Equal(1, resources.UnregisterCount);
}
[Fact]
public void IncarnationCleanup_OldTombstoneCannotMutateReplacementGuidState()
{
const uint guid = 0x70000035u;
var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u));
var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u));
LiveEntityRecord? current = replacement;
var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current);
object oldHost = new();
object newHost = new();
var hosts = new Dictionary<uint, object> { [guid] = newHost };
bool guidStateCleared = false;
var plan = new LiveEntityTeardownPlan(
[
() => cleanup.RunIfNoReplacement(() => guidStateCleared = true),
() => cleanup.RemoveCaptured(hosts, oldHost),
]);
plan.Advance();
Assert.False(guidStateCleared);
Assert.Same(newHost, hosts[guid]);
Assert.True(plan.IsComplete);
}
[Fact]
public void IncarnationCleanup_RemovesOnlyCapturedOwnerAndResumesAfterReplacementEnds()
{
const uint guid = 0x70000036u;
var oldRecord = new LiveEntityRecord(Spawn(guid, 1, 1, 0x01010001u));
var replacement = new LiveEntityRecord(Spawn(guid, 2, 1, 0x01010001u));
LiveEntityRecord? current = replacement;
var cleanup = new LiveEntityIncarnationCleanup(oldRecord, _ => current);
object oldHost = new();
var hosts = new Dictionary<uint, object> { [guid] = oldHost };
int guidCleanupCount = 0;
cleanup.RemoveCaptured(hosts, oldHost);
cleanup.RunIfNoReplacement(() => guidCleanupCount++);
current = null;
cleanup.RunIfNoReplacement(() => guidCleanupCount++);
Assert.Empty(hosts);
Assert.Equal(1, guidCleanupCount);
}
private static LoadedLandblock EmptyLandblock(uint canonicalId) =>
new(canonicalId, new LandBlock(), Array.Empty<WorldEntity>());

View file

@ -0,0 +1,57 @@
using AcDream.App.World;
namespace AcDream.App.Tests.World;
public sealed class LiveEntityTeardownPlanTests
{
[Fact]
public void AdvanceRetriesOnlyFailedSteps()
{
int first = 0;
int middle = 0;
int last = 0;
var plan = new LiveEntityTeardownPlan(
[
() => first++,
() =>
{
middle++;
if (middle == 1)
throw new InvalidOperationException("middle failed");
},
() => last++,
]);
Assert.Throws<AggregateException>(() => plan.Advance());
Assert.Equal(2, plan.CompletedCount);
plan.Advance();
Assert.True(plan.IsComplete);
Assert.Equal(1, first);
Assert.Equal(2, middle);
Assert.Equal(1, last);
}
[Fact]
public void AdvanceAttemptsEveryIndependentFailureInOnePass()
{
int first = 0;
int last = 0;
var plan = new LiveEntityTeardownPlan(
[
() =>
{
first++;
throw new InvalidOperationException("first");
},
() => last++,
]);
Assert.Throws<AggregateException>(() => plan.Advance());
Assert.Equal(1, first);
Assert.Equal(1, last);
Assert.Equal(1, plan.CompletedCount);
}
}