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

@ -81,4 +81,35 @@ public sealed class ContiguousRangeAllocatorTests
Assert.Equal(small.Offset, selected.Offset);
Assert.Equal(40, allocator.LargestFreeRange);
}
[Fact]
public void ReleasingTailLowersLiveHighWaterAndAllowsTrim()
{
var allocator = new ContiguousRangeAllocator(100);
Assert.True(allocator.TryAllocate(20, out _));
Assert.True(allocator.TryAllocate(30, out MeshBufferRange tail));
Assert.Equal(50, allocator.HighWaterMark);
allocator.Release(tail);
Assert.Equal(20, allocator.HighWaterMark);
Assert.Equal(80, allocator.TrailingFreeLength);
allocator.Shrink(40);
Assert.Equal(40, allocator.Capacity);
Assert.Equal(20, allocator.HighWaterMark);
Assert.Equal(20, allocator.TrailingFreeLength);
}
[Fact]
public void InteriorReleaseDoesNotHideAHighLiveAllocation()
{
var allocator = new ContiguousRangeAllocator(100);
Assert.True(allocator.TryAllocate(20, out MeshBufferRange first));
Assert.True(allocator.TryAllocate(30, out _));
allocator.Release(first);
Assert.Equal(50, allocator.HighWaterMark);
Assert.Throws<InvalidOperationException>(() => allocator.Shrink(40));
}
}

View file

@ -15,8 +15,9 @@ public sealed class EntitySpawnAdapterLifetimeTests
const int reuseCount = 32;
const uint guidBase = 0x74000000u;
var meshes = new RefCountingMeshAdapter();
var textures = new RecordingTextureLifetime();
var adapter = new EntitySpawnAdapter(
new NullTextureCache(),
textures,
_ => MakeSequencer(),
meshes);
@ -34,6 +35,7 @@ public sealed class EntitySpawnAdapterLifetimeTests
entity.PaletteOverride,
[new PartOverride(0, 0x01001000u + (uint)i)]);
Assert.NotNull(adapter.OnCreate(entity));
Assert.True(adapter.SetPresentationResident(entity, resident: true));
}
Assert.Equal(ownerCount * 2, meshes.TotalReferenceCount);
@ -49,6 +51,7 @@ public sealed class EntitySpawnAdapterLifetimeTests
replacement.PaletteOverride,
[new PartOverride(0, 0x01002000u + (uint)i)]);
Assert.NotNull(adapter.OnCreate(replacement));
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
}
for (int i = 0; i < ownerCount; i++)
@ -61,6 +64,570 @@ public sealed class EntitySpawnAdapterLifetimeTests
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Empty(meshes.ReferenceCounts);
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Count);
Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Distinct().Count());
}
[Fact]
public void ProjectionSuspendResume_IsIdempotentAndPreservesAnimatedState()
{
const uint guid = 0x74001000u;
var meshes = new RefCountingMeshAdapter();
var textures = new RecordingTextureLifetime();
int sequencerCreates = 0;
var adapter = new EntitySpawnAdapter(
textures,
_ =>
{
sequencerCreates++;
return MakeSequencer();
},
meshes);
WorldEntity entity = MakeEntity(41u, guid);
entity.ApplyAppearance(
[
new MeshRef(0x01000001u, Matrix4x4.Identity),
new MeshRef(0x01000001u, Matrix4x4.Identity),
],
entity.PaletteOverride,
[new PartOverride(0, 0x01000002u)]);
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.False(adapter.SetPresentationResident(entity, resident: false));
Assert.Empty(textures.ReleasedOwnerIds);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.False(adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(2, meshes.TotalReferenceCount);
Assert.True(adapter.SetPresentationResident(entity, resident: false));
Assert.False(adapter.SetPresentationResident(entity, resident: false));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal([entity.Id], textures.ReleasedOwnerIds);
Assert.Same(state, adapter.GetState(guid));
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(2, meshes.TotalReferenceCount);
Assert.Same(state, adapter.GetState(guid));
Assert.Equal(1, sequencerCreates);
adapter.OnRemove(guid);
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal([entity.Id, entity.Id], textures.ReleasedOwnerIds);
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
}
[Fact]
public void RemoveWhileSuspended_DoesNotReleasePresentationResourcesTwice()
{
const uint guid = 0x74002000u;
var meshes = new RefCountingMeshAdapter();
var textures = new RecordingTextureLifetime();
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(51u, guid);
adapter.OnCreate(entity);
adapter.SetPresentationResident(entity, resident: true);
adapter.SetPresentationResident(entity, resident: false);
int decrementsAfterSuspend = meshes.DecrementCount;
adapter.OnRemove(guid);
adapter.OnRemove(guid);
Assert.Null(adapter.GetState(guid));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(decrementsAfterSuspend, meshes.DecrementCount);
Assert.Equal([entity.Id], textures.ReleasedOwnerIds);
}
[Fact]
public void GuidReplacement_IgnoresDelayedEdgesFromDisplacedOwner()
{
const uint guid = 0x74003000u;
var meshes = new RefCountingMeshAdapter();
var textures = new RecordingTextureLifetime();
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity oldEntity = MakeEntity(61u, guid);
oldEntity.MeshRefs = [new MeshRef(0x01000100u, Matrix4x4.Identity)];
WorldEntity replacement = MakeEntity(62u, guid);
replacement.MeshRefs = [new MeshRef(0x01000200u, Matrix4x4.Identity)];
adapter.OnCreate(oldEntity);
adapter.SetPresentationResident(oldEntity, resident: true);
AnimatedEntityState replacementState = Assert.IsType<AnimatedEntityState>(
adapter.OnCreate(replacement));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds);
Assert.False(adapter.OnRemove(oldEntity));
Assert.False(adapter.SetPresentationResident(oldEntity, resident: false));
Assert.False(adapter.SetPresentationResident(oldEntity, resident: true));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
Assert.Equal(1, meshes.TotalReferenceCount);
Assert.Same(replacementState, adapter.GetState(guid));
adapter.OnRemove(guid);
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal([oldEntity.Id, replacement.Id], textures.ReleasedOwnerIds);
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
}
[Fact]
public void GuidReplacement_FactoryFailure_PreservesResidentPriorOwner()
{
const uint guid = 0x74004000u;
var meshes = new RefCountingMeshAdapter();
var textures = new RecordingTextureLifetime();
WorldEntity oldEntity = MakeEntity(71u, guid);
oldEntity.MeshRefs = [new MeshRef(0x01000300u, Matrix4x4.Identity)];
WorldEntity replacement = MakeEntity(72u, guid);
var adapter = new EntitySpawnAdapter(
textures,
entity => ReferenceEquals(entity, replacement)
? throw new InvalidOperationException("replacement factory failure")
: MakeSequencer(),
meshes);
AnimatedEntityState oldState = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(oldEntity));
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
Assert.Throws<InvalidOperationException>(() => adapter.OnCreate(replacement));
Assert.Same(oldState, adapter.GetState(guid));
Assert.Equal(1, meshes.TotalReferenceCount);
Assert.Empty(textures.ReleasedOwnerIds);
Assert.False(adapter.SetPresentationResident(replacement, resident: true));
Assert.True(adapter.SetPresentationResident(oldEntity, resident: false));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds);
}
[Fact]
public void ProjectionSuspend_PartialReleaseFailure_RemainsResidentAndRetriesOnlyUnfinishedResources()
{
const uint guid = 0x74005000u;
const ulong firstMesh = 0x01000400u;
const ulong secondMesh = 0x01000401u;
var meshes = new FaultInjectingMeshAdapter();
var textures = new FaultInjectingTextureLifetime();
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(81u, guid);
entity.MeshRefs =
[
new MeshRef((uint)firstMesh, Matrix4x4.Identity),
new MeshRef((uint)secondMesh, Matrix4x4.Identity),
];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
meshes.FailNextDecrement(secondMesh);
Assert.Throws<AggregateException>(
() => adapter.SetPresentationResident(entity, resident: false));
Assert.NotNull(adapter.GetState(guid));
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(2, meshes.TotalReferenceCount);
Assert.Equal(1, textures.AttemptCount);
Assert.Equal(1, meshes.DecrementAttempts[firstMesh]);
Assert.Equal(1, meshes.DecrementAttempts[secondMesh]);
Assert.True(adapter.SetPresentationResident(entity, resident: false));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(2, textures.AttemptCount);
Assert.Equal(2, meshes.DecrementAttempts[firstMesh]);
Assert.Equal(2, meshes.DecrementAttempts[secondMesh]);
Assert.False(adapter.SetPresentationResident(entity, resident: false));
}
[Fact]
public void LogicalRemove_ReleaseFailure_KeepsOwnerReachableForRetry()
{
const uint guid = 0x74006000u;
const ulong meshId = 0x01000500u;
var meshes = new FaultInjectingMeshAdapter();
var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1);
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(91u, guid);
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.Throws<AggregateException>(() => adapter.OnRemove(entity));
Assert.Same(state, adapter.GetState(guid));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
Assert.Equal(1, textures.AttemptCount);
Assert.True(adapter.OnRemove(entity));
Assert.Null(adapter.GetState(guid));
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
Assert.Equal(2, textures.AttemptCount);
Assert.Equal(1, textures.SuccessCount);
}
[Fact]
public void GuidReplacement_ReleaseFailure_PublishesReplacementOnlyAfterRetrySucceeds()
{
const uint guid = 0x74007000u;
const ulong oldMeshId = 0x01000600u;
var meshes = new FaultInjectingMeshAdapter();
var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1);
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity oldEntity = MakeEntity(101u, guid);
oldEntity.MeshRefs = [new MeshRef((uint)oldMeshId, Matrix4x4.Identity)];
WorldEntity replacement = MakeEntity(102u, guid);
AnimatedEntityState oldState = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(oldEntity));
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
Assert.Throws<AggregateException>(() => adapter.OnCreate(replacement));
Assert.Same(oldState, adapter.GetState(guid));
Assert.False(adapter.SetPresentationResident(replacement, resident: true));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]);
AnimatedEntityState replacementState = Assert.IsType<AnimatedEntityState>(
adapter.OnCreate(replacement));
Assert.Same(replacementState, adapter.GetState(guid));
Assert.False(adapter.OnRemove(oldEntity));
Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]);
Assert.Equal(2, textures.AttemptCount);
}
[Fact]
public void LogicalRemove_ReentrantDuplicate_DoesNotPublishRemovalBeforeCleanupCompletes()
{
const uint guid = 0x74008000u;
var meshes = new FaultInjectingMeshAdapter();
EntitySpawnAdapter? adapter = null;
WorldEntity entity = MakeEntity(111u, guid);
bool invokeNestedRemove = true;
var textures = new FaultInjectingTextureLifetime(
onRelease: () =>
{
if (!invokeNestedRemove)
return;
invokeNestedRemove = false;
Assert.NotNull(adapter!.GetState(guid));
adapter.OnRemove(entity);
});
adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
AggregateException error = Assert.Throws<AggregateException>(() => adapter.OnRemove(entity));
Assert.Contains(
error.Flatten().InnerExceptions,
exception => exception is EntityPresentationRemovalDeferredException);
Assert.NotNull(adapter.GetState(guid));
Assert.True(adapter.OnRemove(entity));
Assert.Null(adapter.GetState(guid));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(2, textures.AttemptCount);
}
[Fact]
public void LogicalRemove_AfterResumeRollbackFailure_ReleasesResidualMeshReference()
{
const uint guid = 0x74009000u;
const ulong firstMesh = 0x01000700u;
const ulong secondMesh = 0x01000701u;
var meshes = new FaultInjectingMeshAdapter();
var textures = new FaultInjectingTextureLifetime();
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(121u, guid);
entity.MeshRefs =
[
new MeshRef((uint)firstMesh, Matrix4x4.Identity),
new MeshRef((uint)secondMesh, Matrix4x4.Identity),
];
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
meshes.FailNextIncrement(secondMesh);
meshes.FailNextDecrement(firstMesh);
Assert.Throws<AggregateException>(
() => adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(1, meshes.TotalReferenceCount);
Assert.Same(state, adapter.GetState(guid));
Assert.True(adapter.OnRemove(entity));
Assert.Null(adapter.GetState(guid));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(2, meshes.DecrementAttempts[firstMesh]);
Assert.Equal(0, textures.AttemptCount);
}
[Fact]
public void Resume_IncrementThrowsAfterCommit_RollsBackCommittedReference()
{
const uint guid = 0x7400A000u;
const ulong meshId = 0x01000800u;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(131u, guid);
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
adapter.OnCreate(entity);
meshes.ThrowAfterNextIncrement(meshId);
Assert.Throws<MeshReferenceMutationException>(
() => adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(1, meshes.TotalReferenceCount);
}
[Fact]
public void Suspend_DecrementThrowsAfterCommit_RetryDoesNotDoubleDecrement()
{
const uint guid = 0x7400B000u;
const ulong meshId = 0x01000900u;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(141u, guid);
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
meshes.ThrowAfterNextDecrement(meshId);
Assert.Throws<AggregateException>(
() => adapter.SetPresentationResident(entity, resident: false));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
Assert.True(adapter.SetPresentationResident(entity, resident: false));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
}
[Fact]
public void AppearanceChange_Resident_AcquiresReplacementBeforePublicationAndRetiresOldAfter()
{
const uint guid = 0x7400C000u;
const ulong oldMesh = 0x01000A00u;
const ulong newMesh = 0x01000A01u;
const ulong overrideMesh = 0x01000A02u;
var meshes = new CallbackMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(151u, guid);
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
meshes.Operations.Clear();
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
PartOverride[] overrides = [new PartOverride(0, (uint)overrideMesh)];
Assert.True(adapter.OnAppearanceChanged(
entity,
replacement,
overrides,
() =>
{
Assert.Equal(1, meshes.ReferenceCounts[oldMesh]);
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]);
meshes.Operations.Add("publish");
entity.ApplyAppearance(replacement, paletteOverride: null, overrides);
}));
int publicationIndex = meshes.Operations.IndexOf("publish");
Assert.True(meshes.Operations.IndexOf($"increment:{newMesh:X8}") < publicationIndex);
Assert.True(meshes.Operations.IndexOf($"increment:{overrideMesh:X8}") < publicationIndex);
Assert.True(meshes.Operations.IndexOf($"decrement:{oldMesh:X8}") > publicationIndex);
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]);
Assert.True(adapter.OnRemove(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
}
[Fact]
public void AppearanceChange_WhileSuspended_OnlyReplacementSetIsAcquiredOnResume()
{
const uint guid = 0x7400D000u;
const ulong oldMesh = 0x01000B00u;
const ulong newMesh = 0x01000B01u;
var meshes = new RefCountingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(161u, guid);
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(entity);
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
Assert.True(adapter.OnAppearanceChanged(
entity,
replacement,
[],
() => entity.ApplyAppearance(replacement, paletteOverride: null, [])));
Assert.Equal(0, meshes.TotalReferenceCount);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
Assert.True(adapter.OnRemove(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
}
[Fact]
public void AppearanceChange_CommittedAcquireFailure_RollsBackAndDoesNotPublish()
{
const uint guid = 0x7400E000u;
const ulong oldMesh = 0x01000C00u;
const ulong newMesh = 0x01000C01u;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(171u, guid);
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
meshes.ThrowAfterNextIncrement(newMesh);
bool published = false;
Assert.Throws<MeshReferenceMutationException>(() =>
adapter.OnAppearanceChanged(
entity,
[new MeshRef((uint)newMesh, Matrix4x4.Identity)],
[],
() => published = true));
Assert.False(published);
Assert.Equal(1, meshes.ReferenceCounts[oldMesh]);
Assert.False(meshes.ReferenceCounts.ContainsKey(newMesh));
Assert.Equal(1, meshes.DecrementAttempts[newMesh]);
Assert.True(adapter.OnRemove(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
}
[Fact]
public void AppearanceChange_ReleaseFailure_RetainsMarkerForResidencyRetry()
{
const uint guid = 0x7400F000u;
const ulong oldMesh = 0x01000D00u;
const ulong newMesh = 0x01000D01u;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(181u, guid);
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
meshes.FailNextDecrement(oldMesh);
int publications = 0;
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
Assert.Throws<AggregateException>(() =>
adapter.OnAppearanceChanged(
entity,
replacement,
[],
() =>
{
publications++;
entity.ApplyAppearance(replacement, paletteOverride: null, []);
}));
Assert.Equal(1, publications);
Assert.Equal(2, meshes.TotalReferenceCount);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
Assert.Equal(1, publications);
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
Assert.Equal(2, meshes.DecrementAttempts[oldMesh]);
Assert.True(adapter.OnRemove(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
}
[Fact]
public void AppearanceChange_ReentrantRequestCannotPublishInsideOuterTransition()
{
const uint guid = 0x74010000u;
const ulong oldMesh = 0x01000E00u;
const ulong outerMesh = 0x01000E01u;
const ulong nestedMesh = 0x01000E02u;
var meshes = new CallbackMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity entity = MakeEntity(191u, guid);
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(entity);
Assert.True(adapter.SetPresentationResident(entity, resident: true));
bool nestedPublished = false;
bool nestedResult = true;
meshes.AfterIncrement = meshId =>
{
if (meshId != outerMesh)
return;
nestedResult = adapter.OnAppearanceChanged(
entity,
[new MeshRef((uint)nestedMesh, Matrix4x4.Identity)],
[],
() => nestedPublished = true);
};
MeshRef[] replacement = [new MeshRef((uint)outerMesh, Matrix4x4.Identity)];
Assert.True(adapter.OnAppearanceChanged(
entity,
replacement,
[],
() => entity.ApplyAppearance(replacement, paletteOverride: null, [])));
Assert.False(nestedResult);
Assert.False(nestedPublished);
Assert.Equal(1, meshes.ReferenceCounts[outerMesh]);
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
Assert.False(meshes.ReferenceCounts.ContainsKey(nestedMesh));
Assert.True(adapter.OnRemove(entity));
Assert.Equal(0, meshes.TotalReferenceCount);
}
[Fact]
public void AppearanceChange_DelayedOldIncarnationCannotAffectGuidReplacement()
{
const uint guid = 0x74011000u;
const ulong oldMesh = 0x01000F00u;
const ulong replacementMesh = 0x01000F01u;
const ulong staleMesh = 0x01000F02u;
var meshes = new RefCountingMeshAdapter();
var adapter = new EntitySpawnAdapter(
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
WorldEntity oldEntity = MakeEntity(201u, guid);
oldEntity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
adapter.OnCreate(oldEntity);
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
WorldEntity replacement = MakeEntity(202u, guid);
replacement.MeshRefs = [new MeshRef((uint)replacementMesh, Matrix4x4.Identity)];
adapter.OnCreate(replacement);
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
bool stalePublished = false;
Assert.False(adapter.OnAppearanceChanged(
oldEntity,
[new MeshRef((uint)staleMesh, Matrix4x4.Identity)],
[],
() => stalePublished = true));
Assert.False(stalePublished);
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
Assert.False(meshes.ReferenceCounts.ContainsKey(staleMesh));
Assert.Equal(1, meshes.ReferenceCounts[replacementMesh]);
Assert.True(adapter.OnRemove(replacement));
Assert.Equal(0, meshes.TotalReferenceCount);
}
private static WorldEntity MakeEntity(uint id, uint serverGuid) => new()
@ -81,12 +648,11 @@ public sealed class EntitySpawnAdapterLifetimeTests
public Animation? LoadAnimation(uint id) => null;
}
private sealed class NullTextureCache : ITextureCachePerInstance
private sealed class RecordingTextureLifetime : IEntityTextureLifetime
{
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride) => 1u;
public List<uint> ReleasedOwnerIds { get; } = new();
public void ReleaseOwner(uint localEntityId) => ReleasedOwnerIds.Add(localEntityId);
}
private sealed class RefCountingMeshAdapter : IWbMeshAdapter
@ -113,4 +679,118 @@ public sealed class EntitySpawnAdapterLifetimeTests
DecrementCount++;
}
}
private sealed class CallbackMeshAdapter : IWbMeshAdapter
{
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public List<string> Operations { get; } = new();
public Action<ulong>? AfterIncrement { get; set; }
public int TotalReferenceCount => ReferenceCounts.Values.Sum();
public void IncrementRefCount(ulong id)
{
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
Operations.Add($"increment:{id:X8}");
AfterIncrement?.Invoke(id);
}
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;
Operations.Add($"decrement:{id:X8}");
}
}
private sealed class FaultInjectingTextureLifetime(
int failuresBeforeSuccess = 0,
Action? onRelease = null) : IEntityTextureLifetime
{
private int _failuresRemaining = failuresBeforeSuccess;
public int AttemptCount { get; private set; }
public int SuccessCount { get; private set; }
public void ReleaseOwner(uint localEntityId)
{
AttemptCount++;
onRelease?.Invoke();
if (_failuresRemaining > 0)
{
_failuresRemaining--;
throw new InvalidOperationException("injected texture release failure");
}
SuccessCount++;
}
}
private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter
{
private readonly Dictionary<ulong, int> _remainingIncrementFailures = new();
private readonly Dictionary<ulong, int> _remainingDecrementFailures = new();
private readonly HashSet<ulong> _throwAfterIncrement = new();
private readonly HashSet<ulong> _throwAfterDecrement = new();
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public Dictionary<ulong, int> DecrementAttempts { get; } = new();
public int TotalReferenceCount => ReferenceCounts.Values.Sum();
public void FailNextIncrement(ulong id) =>
_remainingIncrementFailures[id] =
_remainingIncrementFailures.GetValueOrDefault(id) + 1;
public void FailNextDecrement(ulong id) =>
_remainingDecrementFailures[id] =
_remainingDecrementFailures.GetValueOrDefault(id) + 1;
public void ThrowAfterNextIncrement(ulong id) => _throwAfterIncrement.Add(id);
public void ThrowAfterNextDecrement(ulong id) => _throwAfterDecrement.Add(id);
public void IncrementRefCount(ulong id)
{
if (_remainingIncrementFailures.GetValueOrDefault(id) > 0)
{
_remainingIncrementFailures[id]--;
throw new InvalidOperationException("injected mesh acquire failure");
}
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
if (_throwAfterIncrement.Remove(id))
{
throw new MeshReferenceMutationException(
"injected post-commit mesh acquire failure",
mutationCommitted: true,
new InvalidOperationException("injected metadata failure"));
}
}
public void DecrementRefCount(ulong id)
{
DecrementAttempts[id] = DecrementAttempts.GetValueOrDefault(id) + 1;
if (_remainingDecrementFailures.GetValueOrDefault(id) > 0)
{
_remainingDecrementFailures[id]--;
throw new InvalidOperationException("injected mesh release failure");
}
Assert.True(ReferenceCounts.TryGetValue(id, out int count));
Assert.True(count > 0);
if (count == 1)
ReferenceCounts.Remove(id);
else
ReferenceCounts[id] = count - 1;
if (_throwAfterDecrement.Remove(id))
{
throw new MeshReferenceMutationException(
"injected post-commit mesh release failure",
mutationCommitted: true,
new InvalidOperationException("injected cancellation failure"));
}
}
}
}

View file

@ -13,6 +13,49 @@ namespace AcDream.App.Tests.Rendering.Wb;
public class EnvCellRendererTests
{
[Fact]
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4);
Assert.Equal(
[new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)],
ranges);
}
[Fact]
public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1);
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2);
Assert.Equal(
[
new EnvCellRenderer.MdiDrawRange(2, 0, 3),
new EnvCellRenderer.MdiDrawRange(6, 3, 2),
new EnvCellRenderer.MdiDrawRange(2, 5, 1),
new EnvCellRenderer.MdiDrawRange(2, 9, 2),
],
ranges);
}
[Fact]
public void OrderedMdiRanges_IgnoreEmptyCellRanges()
{
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0);
Assert.Empty(ranges);
}
// -----------------------------------------------------------------------
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
// -----------------------------------------------------------------------

View file

@ -0,0 +1,137 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class GpuRetiredRangeAllocatorTests
{
[Fact]
public void ReleasedRangeCannotBeOverwrittenBeforeGpuRetirement()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange first));
allocator.ReleaseAfterGpuUse(first);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
Assert.False(allocator.TryAllocate(8, out _));
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
Assert.Equal(0, allocator.PendingReleaseLength);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
Assert.Equal(first, reused);
}
[Fact]
public void FailedUploadCanReleaseUnsubmittedRangeImmediately()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange first));
allocator.ReleaseUnsubmitted(first);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
Assert.Equal(first, reused);
Assert.Equal(0, retirement.Count);
}
[Fact]
public void FailedRetirement_KeepsPendingOwnershipAccounting()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
allocator.ReleaseAfterGpuUse(range);
retirement.RunAll();
allocator.ReleaseAfterGpuUse(range); // injected duplicate makes Release fail before commit
Assert.Throws<InvalidOperationException>(retirement.RunAll);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
}
[Fact]
public void PublicationFailure_RetryDoesNotDuplicatePendingAccounting()
{
var retirement = new DeferredRetirementQueue { FailNextPublication = true };
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
Assert.Throws<InvalidOperationException>(() => allocator.ReleaseAfterGpuUse(range));
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
Assert.Equal(0, retirement.Count);
allocator.ReleaseAfterGpuUse(range);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
Assert.Equal(1, retirement.Count);
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
Assert.Equal(0, allocator.PendingReleaseLength);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
Assert.Equal(range, reused);
}
[Fact]
public void DuplicateReleaseBeforeRetirement_DoesNotPublishTwice()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
allocator.ReleaseAfterGpuUse(range);
allocator.ReleaseAfterGpuUse(range);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
Assert.Equal(1, retirement.Count);
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
Assert.Equal(0, allocator.PendingReleaseLength);
}
[Fact]
public void PendingSubmittedRange_CannotBeReleasedAsUnsubmitted()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
allocator.ReleaseAfterGpuUse(range);
Assert.Throws<InvalidOperationException>(() => allocator.ReleaseUnsubmitted(range));
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(8, allocator.PendingReleaseLength);
}
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
{
private readonly List<Action> _releases = [];
public int Count => _releases.Count;
public bool FailNextPublication { get; set; }
public void Retire(Action release)
{
if (FailNextPublication)
{
FailNextPublication = false;
throw new InvalidOperationException("Injected publication failure.");
}
_releases.Add(release);
}
public void RunAll()
{
foreach (Action release in _releases)
release();
_releases.Clear();
}
}
}

View file

@ -81,6 +81,313 @@ public sealed class LandblockSpawnAdapterReadinessTests
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts);
}
[Fact]
public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences()
{
const ulong first = 0x01000010ul;
const ulong failed = 0x01000020ul;
const ulong last = 0x01000030ul;
var meshes = new FaultInjectingMeshAdapter();
meshes.FailNext(ReferenceOperation.Increment, failed, committed: false);
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last);
Assert.Throws<InvalidOperationException>(() => adapter.OnLandblockLoaded(landblock));
Assert.Equal(1, meshes.ReferenceCounts[first]);
Assert.DoesNotContain(failed, meshes.ReferenceCounts);
Assert.Equal(1, meshes.ReferenceCounts[last]);
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
adapter.OnLandblockLoaded(landblock);
Assert.Equal(1, meshes.ReferenceCounts[first]);
Assert.Equal(1, meshes.ReferenceCounts[failed]);
Assert.Equal(1, meshes.ReferenceCounts[last]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, first));
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Increment, failed));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, last));
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
}
[Fact]
public void Load_CommittedPreparedPinFailure_RetryDoesNotDoubleAcquire()
{
const ulong prepared = 0x2_0000_1234ul;
var meshes = new FaultInjectingMeshAdapter();
meshes.FailNext(ReferenceOperation.PinPrepared, prepared, committed: true);
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu);
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
() => adapter.OnLandblockLoaded(landblock, new[] { prepared }));
Assert.True(error.MutationCommitted);
Assert.Equal(1, meshes.ReferenceCounts[prepared]);
adapter.OnLandblockLoaded(landblock, new[] { prepared });
Assert.Equal(1, meshes.ReferenceCounts[prepared]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.PinPrepared, prepared));
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
}
[Fact]
public void Load_CommittedIncrementFailure_RetryDoesNotDoubleAcquire()
{
const ulong id = 0x01000010ul;
var meshes = new FaultInjectingMeshAdapter();
meshes.FailNext(ReferenceOperation.Increment, id, committed: true);
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id);
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
() => adapter.OnLandblockLoaded(landblock));
Assert.True(error.MutationCommitted);
Assert.Equal(1, meshes.ReferenceCounts[id]);
adapter.OnLandblockLoaded(landblock);
Assert.Equal(1, meshes.ReferenceCounts[id]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, id));
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
}
[Fact]
public void Unload_BeforeCommitFailure_RetryReleasesOnlyStillHeldReferences()
{
const ulong first = 0x01000010ul;
const ulong failed = 0x01000020ul;
const ulong last = 0x01000030ul;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last);
adapter.OnLandblockLoaded(landblock);
meshes.FailNext(ReferenceOperation.Decrement, failed, committed: false);
Assert.Throws<InvalidOperationException>(
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
Assert.Single(meshes.ReferenceCounts);
Assert.Equal(1, meshes.ReferenceCounts[failed]);
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, first));
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, last));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed));
}
[Fact]
public void Unload_CommittedFailure_DropsCompletedRegistrationBeforePropagating()
{
const ulong id = 0x01000010ul;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id);
adapter.OnLandblockLoaded(landblock);
meshes.FailNext(ReferenceOperation.Decrement, id, committed: true);
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
Assert.True(error.MutationCommitted);
Assert.Empty(meshes.ReferenceCounts);
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, id));
}
[Fact]
public void OppositeUnloadAfterPartialLoad_ReleasesOnlyReferencesActuallyAcquired()
{
const ulong held = 0x01000010ul;
const ulong neverAcquired = 0x01000020ul;
var meshes = new FaultInjectingMeshAdapter();
meshes.FailNext(ReferenceOperation.Increment, neverAcquired, committed: false);
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(
0x1234FFFFu,
(uint)held,
(uint)neverAcquired);
Assert.Throws<InvalidOperationException>(() => adapter.OnLandblockLoaded(landblock));
adapter.OnLandblockUnloaded(landblock.LandblockId);
Assert.Empty(meshes.ReferenceCounts);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, held));
Assert.Equal(0, meshes.CallCount(ReferenceOperation.Decrement, neverAcquired));
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
}
[Fact]
public void OppositeLoadAfterPartialUnload_ReusesOverlapAndReconcilesObsoleteResidual()
{
const ulong released = 0x01000010ul;
const ulong residual = 0x01000020ul;
const ulong newlyDesired = 0x01000030ul;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock original = MakeLandblock(
0x1234FFFFu,
(uint)released,
(uint)residual);
adapter.OnLandblockLoaded(original);
meshes.FailNext(ReferenceOperation.Decrement, residual, committed: false);
Assert.Throws<InvalidOperationException>(
() => adapter.OnLandblockUnloaded(original.LandblockId));
// The residual id is intentionally absent from the replacement. The
// reverse edge must finish its old release while acquiring the new
// snapshot, without reacquiring the already-released first id.
LoadedLandblock replacement = MakeLandblock(
original.LandblockId,
(uint)newlyDesired);
adapter.OnLandblockLoaded(replacement);
Assert.Single(meshes.ReferenceCounts);
Assert.Equal(1, meshes.ReferenceCounts[newlyDesired]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, released));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residual));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, newlyDesired));
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, residual));
Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId));
}
[Fact]
public void OppositeLoadAfterPartialUnload_RetainsStillHeldOverlapWithoutMutation()
{
const ulong released = 0x01000010ul;
const ulong residualOverlap = 0x01000020ul;
var meshes = new FaultInjectingMeshAdapter();
var adapter = new LandblockSpawnAdapter(meshes);
LoadedLandblock landblock = MakeLandblock(
0x1234FFFFu,
(uint)released,
(uint)residualOverlap);
adapter.OnLandblockLoaded(landblock);
meshes.FailNext(ReferenceOperation.Decrement, residualOverlap, committed: false);
Assert.Throws<InvalidOperationException>(
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
LoadedLandblock replacement = MakeLandblock(
landblock.LandblockId,
(uint)residualOverlap);
adapter.OnLandblockLoaded(replacement);
Assert.Single(meshes.ReferenceCounts);
Assert.Equal(1, meshes.ReferenceCounts[residualOverlap]);
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residualOverlap));
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, residualOverlap));
Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId));
}
private static LoadedLandblock MakeLandblock(uint landblockId, params uint[] gfxObjIds)
{
WorldEntity[] entities = gfxObjIds.Length == 0
? Array.Empty<WorldEntity>()
:
[
new WorldEntity
{
Id = 1,
ServerGuid = 0,
SourceGfxObjOrSetupId = gfxObjIds[0],
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = gfxObjIds
.Select(id => new MeshRef(id, Matrix4x4.Identity))
.ToArray(),
},
];
return new LoadedLandblock(landblockId, new LandBlock(), entities);
}
private enum ReferenceOperation
{
Increment,
PinPrepared,
Decrement,
}
private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter
{
private readonly Dictionary<(ReferenceOperation Operation, ulong Id), Queue<bool>> _failures = new();
private readonly List<(ReferenceOperation Operation, ulong Id)> _calls = new();
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
public void FailNext(ReferenceOperation operation, ulong id, bool committed)
{
if (!_failures.TryGetValue((operation, id), out Queue<bool>? failures))
{
failures = new Queue<bool>();
_failures.Add((operation, id), failures);
}
failures.Enqueue(committed);
}
public int CallCount(ReferenceOperation operation, ulong id)
=> _calls.Count(call => call == (operation, id));
public void IncrementRefCount(ulong id)
=> Mutate(ReferenceOperation.Increment, id, delta: 1);
public void PinPreparedRenderData(ulong id)
=> Mutate(ReferenceOperation.PinPrepared, id, delta: 1);
public void DecrementRefCount(ulong id)
=> Mutate(ReferenceOperation.Decrement, id, delta: -1);
public bool IsRenderDataReady(ulong id) => ReferenceCounts.ContainsKey(id);
private void Mutate(ReferenceOperation operation, ulong id, int delta)
{
_calls.Add((operation, id));
if (_failures.TryGetValue((operation, id), out Queue<bool>? failures)
&& failures.TryDequeue(out bool committed))
{
if (committed)
ApplyDelta(id, delta);
if (failures.Count == 0)
_failures.Remove((operation, id));
var inner = new InvalidOperationException("Injected mesh-reference failure.");
if (committed)
{
throw new MeshReferenceMutationException(
"Injected committed mesh-reference failure.",
mutationCommitted: true,
inner);
}
throw inner;
}
ApplyDelta(id, delta);
}
private void ApplyDelta(ulong id, int delta)
{
int next = ReferenceCounts.GetValueOrDefault(id) + delta;
if (next < 0)
throw new InvalidOperationException($"Reference underflow for 0x{id:X}.");
if (next == 0)
ReferenceCounts.Remove(id);
else
ReferenceCounts[id] = next;
}
}
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
{
public HashSet<ulong> ReadyIds { get; } = new();

View file

@ -21,17 +21,17 @@ public sealed class MeshUploadCachesTests
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out var initial));
Assert.Same(data, initial);
staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted.
Assert.Same(data, initial.Data);
staging.Complete(initial); // GPU upload completed, then was later evicted.
data.UploadAttempts = 3;
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached));
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached, out _));
Assert.Same(data, cached);
Assert.Equal(0, data.UploadAttempts);
Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData);
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _));
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _, out _));
Assert.True(staging.TryDequeue(out var restaged));
Assert.Same(data, restaged);
Assert.Same(data, restaged.Data);
Assert.False(staging.TryDequeue(out _));
}
@ -43,12 +43,12 @@ public sealed class MeshUploadCachesTests
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out var attempt));
staging.Requeue(attempt!);
staging.Requeue(attempt);
Assert.False(staging.Stage(data));
Assert.True(staging.TryDequeue(out var retry));
Assert.Same(data, retry);
Assert.Same(data, retry.Data);
staging.Complete(data.ObjectId);
staging.Complete(retry);
Assert.True(staging.Stage(data));
}
@ -66,4 +66,244 @@ public sealed class MeshUploadCachesTests
Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload
Assert.Equal(0, ownership.Count(id));
}
[Fact]
public void StagingQueueRejectsProducerWorkAboveByteHighWater()
{
var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64);
ObjectMeshData first = DataWithTexture(1, 40);
ObjectMeshData second = DataWithTexture(2, 40);
Assert.True(staging.Stage(first));
Assert.False(staging.Stage(second));
Assert.Equal(1, staging.Count);
Assert.Equal(40, staging.QueuedBytes);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem firstItem));
staging.Complete(firstItem);
Assert.True(staging.Stage(second));
}
[Fact]
public void CpuCacheHitReportsHighWaterWithoutPretendingItWasStaged()
{
var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64);
var cache = new CpuMeshUploadCache(capacity: 2);
ObjectMeshData queued = DataWithTexture(1, 40);
ObjectMeshData cached = DataWithTexture(2, 40);
cache.Store(cached);
Assert.True(staging.Stage(queued));
Assert.True(cache.TryGetAndStage(2, staging, out ObjectMeshData? found, out MeshStageResult result));
Assert.Same(cached, found);
Assert.Equal(MeshStageResult.HighWater, result);
Assert.Equal(1, staging.Count);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem queuedItem));
staging.Complete(queuedItem);
Assert.True(cache.TryGetAndStage(2, staging, out _, out result));
Assert.Equal(MeshStageResult.Staged, result);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem staged));
Assert.Same(cached, staged.Data);
}
[Fact]
public void StalePrefixDrainIsBoundedAndDoesNotConsumeOwnedHead()
{
const int total = 1000;
var staging = new MeshUploadStagingQueue(
maximumCount: total + 1,
maximumBytes: long.MaxValue);
var ownership = new MeshOwnershipCounter();
for (ulong id = 1; id <= total; id++)
Assert.True(staging.Stage(new ObjectMeshData { ObjectId = id }));
ownership.Acquire(total);
Assert.Equal(64, staging.DiscardUnownedPrefix(ownership, maximum: 64));
Assert.Equal(total - 64, staging.Count);
Assert.Equal(total - 65, staging.DiscardUnownedPrefix(ownership, maximum: total));
Assert.True(staging.TryPeek(out MeshUploadQueueItem owned));
Assert.Equal((ulong)total, owned.Data.ObjectId);
}
[Fact]
public void CpuCacheRearmsMeshAfterStaleStageWasDiscarded()
{
const ulong id = 0x01000010;
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = new MeshOwnershipCounter();
var data = DataWithTexture(id, 16);
cache.Store(data);
Assert.True(staging.Stage(data));
Assert.Equal(1, staging.DiscardUnownedPrefix(ownership, maximum: 1));
Assert.Equal(0, staging.Count);
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? rearmed, out _));
Assert.Same(data, rearmed);
Assert.Equal(0, staging.DiscardUnownedPrefix(ownership, maximum: 1));
Assert.True(staging.TryPeek(out MeshUploadQueueItem queued));
Assert.Same(data, queued.Data);
}
[Fact]
public void CpuCacheUsesByteBudgetInAdditionToEntryCount()
{
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 10, byteCapacity: 64);
cache.Store(DataWithTexture(1, 40));
cache.Store(DataWithTexture(2, 40));
Assert.False(cache.TryGetAndStage(1, staging, out _, out _));
Assert.True(cache.TryGetAndStage(2, staging, out _, out _));
Assert.Equal(1, cache.Count);
Assert.Equal(40, cache.ResidentBytes);
}
[Fact]
public void CpuCacheRejectsOversizedEntryBeforeEvictingOrPublishingIt()
{
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
ObjectMeshData retained = DataWithTexture(1, 40);
ObjectMeshData oversized = DataWithTexture(2, 80);
Assert.True(cache.Store(retained));
Assert.False(cache.Store(oversized));
Assert.Equal(1, cache.Count);
Assert.Equal(40, cache.ResidentBytes);
Assert.False(cache.TryGetAndStage(2, staging, out _, out _));
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
Assert.Same(retained, found);
}
[Fact]
public void OversizedReplacementCannotDisplaceBoundedCachedGeneration()
{
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
ObjectMeshData retained = DataWithTexture(1, 40);
Assert.True(cache.Store(retained));
Assert.False(cache.Store(DataWithTexture(1, 80)));
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
Assert.Same(retained, found);
Assert.Equal(40, cache.ResidentBytes);
}
[Fact]
public void DequeuedUnownedGenerationHandsOffToRacingCacheHit()
{
const ulong id = 0x01000010;
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = new MeshOwnershipCounter();
ObjectMeshData data = DataWithTexture(id, 16);
cache.Store(data);
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued));
// The new owner cache-hits while the dequeued generation still owns
// staging's id claim, so its direct Stage attempt cannot enqueue yet.
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
Assert.Same(data, cached);
Assert.Equal(0, staging.Count);
Assert.True(staging.CompleteOrRestageIfOwned(dequeued, ownership));
Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff));
Assert.Same(data, handedOff.Data);
Assert.False(staging.TryDequeue(out _));
}
[Fact]
public void OwnerArrivingAfterUnownedCompletionStagesCachedPayloadExactlyOnce()
{
const ulong id = 0x01000010;
var staging = new MeshUploadStagingQueue();
var cache = new CpuMeshUploadCache(capacity: 2);
var ownership = new MeshOwnershipCounter();
ObjectMeshData data = DataWithTexture(id, 16);
cache.Store(data);
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued));
Assert.False(staging.CompleteOrRestageIfOwned(dequeued, ownership));
Assert.Equal(0, staging.Count);
ownership.Acquire(id);
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
Assert.Same(data, cached);
Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff));
Assert.Same(data, handedOff.Data);
Assert.False(staging.TryDequeue(out _));
}
[Fact]
public void ReacquiredObjectGetsNewImmutableQueueGeneration()
{
var staging = new MeshUploadStagingQueue();
var data = new ObjectMeshData { ObjectId = 0x01000010 };
Assert.True(staging.Stage(data));
Assert.True(staging.TryDequeue(out MeshUploadQueueItem first));
staging.Complete(first);
Assert.True(staging.Stage(data));
Assert.True(staging.TryPeek(out MeshUploadQueueItem replacement));
Assert.NotEqual(first.Generation, replacement.Generation);
Assert.Throws<InvalidOperationException>(() => staging.Complete(first));
Assert.True(staging.TryPeek(out MeshUploadQueueItem stillQueued));
Assert.Equal(replacement.Generation, stillQueued.Generation);
}
[Fact]
public void OversizedHeadIsExplicitlyBoundedAndFollowingProducerBackpressures()
{
var staging = new MeshUploadStagingQueue(
maximumCount: 8,
maximumBytes: 64,
maximumSingleEntryBytes: 96);
ObjectMeshData oversized = DataWithTexture(1, 80);
Assert.True(staging.Stage(oversized));
Assert.False(staging.Stage(DataWithTexture(2, 1)));
Assert.Throws<NotSupportedException>(() => staging.Stage(DataWithTexture(3, 97)));
Assert.Equal(1, staging.Count);
Assert.Equal(80, staging.QueuedBytes);
}
[Fact]
public void DequeuedGenerationStillCountsAgainstProducerByteAndCountBounds()
{
var staging = new MeshUploadStagingQueue(maximumCount: 1, maximumBytes: 64);
ObjectMeshData first = DataWithTexture(1, 40);
Assert.True(staging.Stage(first));
Assert.True(staging.TryDequeue(out MeshUploadQueueItem inFlight));
Assert.Equal(0, staging.Count);
Assert.Equal(1, staging.ClaimCount);
Assert.Equal(0, staging.QueuedBytes);
Assert.Equal(40, staging.ClaimedBytes);
Assert.True(staging.IsAtHighWater);
Assert.False(staging.Stage(DataWithTexture(2, 1)));
staging.Complete(inFlight);
Assert.True(staging.Stage(DataWithTexture(2, 1)));
}
private static ObjectMeshData DataWithTexture(ulong id, int bytes)
{
var data = new ObjectMeshData { ObjectId = id };
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] =
[
new TextureBatchData { TextureData = new byte[bytes] },
];
return data;
}
}

View file

@ -0,0 +1,292 @@
using AcDream.App.Rendering.Wb;
using AcDream.Content;
using Chorizite.Core.Render.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class ObjectMeshUploadBudgetTests
{
[Fact]
public void EstimateIncludesVerticesIndicesTexturesAndNestedGeometry()
{
var nested = new ObjectMeshData
{
Vertices = new VertexPositionNormalTexture[2],
TextureBatches =
{
[(8, 8, TextureFormat.RGBA8)] =
[
new TextureBatchData
{
TextureData = new byte[256],
Indices = [0, 1, 2],
},
],
},
};
var setup = new ObjectMeshData
{
IsSetup = true,
EnvCellGeometry = nested,
};
long expected = 1024
+ 2L * VertexPositionNormalTexture.Size
+ 256
+ 3L * sizeof(ushort);
Assert.Equal(expected, ObjectMeshManager.EstimateUploadBytes(setup));
}
[Fact]
public void First512RgbaLayerIncludesArrayAndWholeMipChain()
{
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
512,
512,
TextureFormat.RGBA8,
uploadBytes: 512 * 512 * 4,
sourceBytes: 512 * 512 * 4);
long expectedArray = TextureAtlasManager.CalculateArrayBytes(512, 512, TextureFormat.RGBA8);
Assert.Equal(expectedArray, cost.ArrayAllocationBytes);
Assert.Equal(expectedArray, cost.MipmapBytes);
Assert.Equal(1, cost.NewArrayCount);
}
[Fact]
public void First1024RgbaLayerUsesOneLayerAtlasCapacity()
{
const int layerBytes = 1024 * 1024 * 4;
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
1024,
1024,
TextureFormat.RGBA8,
uploadBytes: layerBytes,
sourceBytes: layerBytes);
Assert.Equal(1, TextureAtlasManager.CalculateInitialCapacity(1024, 1024, TextureFormat.RGBA8));
Assert.Equal(5_592_404, cost.ArrayAllocationBytes);
}
[Fact]
public void First512RgbaLayerHasFixedGoldenPhysicalCosts()
{
const int layerBytes = 512 * 512 * 4;
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
512,
512,
TextureFormat.RGBA8,
uploadBytes: layerBytes);
Assert.Equal(6, TextureAtlasManager.CalculateInitialCapacity(512, 512, TextureFormat.RGBA8));
Assert.Equal(8_388_600, cost.ArrayAllocationBytes);
Assert.Equal(8_388_600, cost.MipmapBytes);
}
[Fact]
public void Five512RgbaLayersChargeOneArrayAndOneMipGeneration()
{
const int layerBytes = 512 * 512 * 4;
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasUploadCost(
512,
512,
TextureFormat.RGBA8,
[layerBytes, layerBytes, layerBytes, layerBytes, layerBytes]);
Assert.Equal(8_388_600, cost.ArrayAllocationBytes);
Assert.Equal(8_388_600, cost.MipmapBytes);
Assert.Equal(1, cost.NewArrayCount);
}
[Fact]
public void BoundedIndivisibleHeadRunsImmediatelyWithoutVirtualReservation()
{
var limits = new MeshUploadBudgetLimits(
MaximumObjects: 4,
MaximumSourceBytes: 100,
MaximumArrayAllocationBytes: 100,
MaximumMipmapBytes: 100,
MaximumNewArrays: 2,
MaximumSingleSourceBytes: 200,
MaximumSingleArrayAllocationBytes: 200,
MaximumSingleMipmapBytes: 200,
MaximumSingleNewArrays: 4);
var budget = new MeshUploadFrameBudget(limits);
var oversized = new MeshUploadCost(150, 150, 150, 3);
Assert.True(budget.TryAdmit(oversized));
Assert.False(budget.TryAdmit(new MeshUploadCost(1, 0, 0, 0)));
Assert.Equal(1, budget.ObjectCount);
}
[Fact]
public void HeadAboveExplicitSingleOperationCeilingFailsLoudly()
{
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
MaximumObjects: 4,
MaximumSourceBytes: 100,
MaximumArrayAllocationBytes: 100,
MaximumMipmapBytes: 100,
MaximumNewArrays: 1,
MaximumSingleSourceBytes: 200));
Assert.Throws<NotSupportedException>(
() => budget.TryAdmit(new MeshUploadCost(250, 0, 0, 0, AdmissionKey: 17)));
}
[Fact]
public void BufferMigrationCostCannotBePretendedAdmitted()
{
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
MaximumObjects: 4,
MaximumSourceBytes: 100,
MaximumArrayAllocationBytes: 100,
MaximumMipmapBytes: 100,
MaximumNewArrays: 1));
Assert.Throws<InvalidOperationException>(
() => budget.TryAdmit(new MeshUploadCost(
1, 0, 0, 0,
BufferAllocationBytes: 1,
BufferCopyBytes: 1,
NewBufferCount: 1)));
}
[Fact]
public void GlobalArenaGrowthIsGeometricAndAvoidsRepeatedPrefixCopies()
{
Assert.Equal(
1_500,
GlobalMeshBuffer.CalculateGrowthCapacity(
capacity: 1_000,
trailingFreeLength: 100,
requiredContiguousLength: 300,
growthQuantum: 50));
Assert.Equal(
1_000,
GlobalMeshBuffer.CalculateGrowthCapacity(
capacity: 1_000,
trailingFreeLength: 300,
requiredContiguousLength: 300,
growthQuantum: 50));
}
[Fact]
public void GlobalArenaTrimUsesThreeToOneHysteresisAndKeepsDoubleHeadroom()
{
Assert.True(GlobalMeshBuffer.TryCalculateTrimCapacity(
capacity: 8_192,
highWaterMark: 1_000,
initialCapacity: 512,
growthQuantum: 256,
out int trimmed));
Assert.Equal(2_048, trimmed);
Assert.False(GlobalMeshBuffer.TryCalculateTrimCapacity(
capacity: 2_048,
highWaterMark: 1_200,
initialCapacity: 512,
growthQuantum: 256,
out _));
}
[Fact]
public void FrameBudgetRejectsPrefixItemWhenAnySingleDimensionWouldOverflow()
{
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
MaximumObjects: 4,
MaximumSourceBytes: 100,
MaximumArrayAllocationBytes: 100,
MaximumMipmapBytes: 100,
MaximumNewArrays: 2,
MaximumBufferUploadBytes: 100,
MaximumBufferAllocationBytes: 100,
MaximumBufferCopyBytes: 100,
MaximumNewBuffers: 2));
Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40)));
Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40)));
Assert.False(budget.TryAdmit(new MeshUploadCost(1, 1, 1, 0, BufferUploadBytes: 21)));
Assert.Equal(2, budget.ObjectCount);
Assert.Equal(80, budget.BufferUploadBytes);
}
[Fact]
public void MigrationCopyChunksNeverExceedRealFrameBudget()
{
const long budget = 32L * 1024 * 1024;
const long total = 100L * 1024 * 1024;
Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, 0, budget));
Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, budget, budget));
Assert.Equal(4L * 1024 * 1024,
GlobalMeshBuffer.CalculateCopyChunk(total, 96L * 1024 * 1024, budget));
Assert.Equal(0, GlobalMeshBuffer.CalculateCopyChunk(total, total, budget));
}
[Fact]
public void ModernArenaGeometryIsCountedOnlyByPhysicalBackingCapacity()
{
const long arenaCapacity = 512;
const long geometryRanges = 100;
long modernNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes(
usesGlobalArena: true,
geometryRanges);
long legacyNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes(
usesGlobalArena: false,
geometryRanges);
Assert.Equal(0, modernNonArena);
Assert.Equal(arenaCapacity,
ObjectMeshManager.CalculateTrackedGpuBytes(modernNonArena, arenaCapacity));
Assert.Equal(arenaCapacity + geometryRanges,
ObjectMeshManager.CalculateTrackedGpuBytes(legacyNonArena, arenaCapacity));
Assert.True(ObjectMeshManager.IsWithinGpuCacheBudget(0, arenaCapacity, 600));
Assert.False(ObjectMeshManager.IsWithinGpuCacheBudget(100, arenaCapacity, 600));
}
[Fact]
public void SetupDependencyRollbackAttemptsEveryPartAndRetriesOnlyFailure()
{
var calls = new Dictionary<ulong, int>();
RetryableResourceReleaseLedger rollback =
ObjectMeshManager.CreateSetupPartRollback(
[1, 2, 3],
id =>
{
calls[id] = calls.GetValueOrDefault(id) + 1;
if (id == 2 && calls[id] == 1)
throw new InvalidOperationException("part release");
});
ResourceReleaseAttempt first = rollback.Advance();
Assert.Single(first.Failures);
Assert.Equal(1, calls[1]);
Assert.Equal(1, calls[2]);
Assert.Equal(1, calls[3]);
ResourceReleaseAttempt retry = rollback.Advance();
Assert.Empty(retry.Failures);
Assert.True(rollback.IsComplete);
Assert.Equal(1, calls[1]);
Assert.Equal(2, calls[2]);
Assert.Equal(1, calls[3]);
}
[Fact]
public void ReclamationBudgetCanSkipOversizedHeadAndAdmitSmallerCandidate()
{
Assert.False(ObjectMeshManager.FitsReclamationBudget(
candidateBytes: 80,
alreadyReclaimedBytes: 0,
maximumBytes: 64));
Assert.True(ObjectMeshManager.FitsReclamationBudget(
candidateBytes: 16,
alreadyReclaimedBytes: 0,
maximumBytes: 64));
}
}

View file

@ -0,0 +1,177 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class RetryableResourceReleaseLedgerTests
{
[Fact]
public void Advance_AttemptsEveryStage_AndRetriesOnlyUnfinishedStage()
{
int firstCalls = 0;
int flakyCalls = 0;
int lastCalls = 0;
var ledger = new RetryableResourceReleaseLedger(
[
("first", () => firstCalls++),
("flaky", () =>
{
flakyCalls++;
if (flakyCalls == 1)
throw new InvalidOperationException("injected pre-commit failure");
}),
("last", () => lastCalls++),
]);
ResourceReleaseAttempt first = ledger.Advance();
Assert.False(ledger.IsComplete);
Assert.Equal(1, ledger.RemainingCount);
Assert.Equal(3, first.AttemptedCount);
Assert.Equal(2, first.CompletedCount);
Assert.Single(first.Failures);
Assert.False(first.Failures[0].MutationCommitted);
Assert.Equal(1, firstCalls);
Assert.Equal(1, flakyCalls);
Assert.Equal(1, lastCalls);
ResourceReleaseAttempt retry = ledger.Advance();
Assert.True(ledger.IsComplete);
Assert.Equal(0, ledger.RemainingCount);
Assert.Equal(1, retry.AttemptedCount);
Assert.Equal(1, retry.CompletedCount);
Assert.Empty(retry.Failures);
Assert.Equal(1, firstCalls);
Assert.Equal(2, flakyCalls);
Assert.Equal(1, lastCalls);
}
[Fact]
public void Advance_CommittedExceptionalMutation_IsRecordedButNeverReplayed()
{
int committedCalls = 0;
int laterCalls = 0;
var ledger = new RetryableResourceReleaseLedger(
[
("committed", () =>
{
committedCalls++;
throw new MeshReferenceMutationException(
"injected post-commit failure",
mutationCommitted: true,
new InvalidOperationException("observer failed"));
}),
("later", () => laterCalls++),
]);
ResourceReleaseAttempt attempt = ledger.Advance();
Assert.True(ledger.IsComplete);
Assert.Equal(2, attempt.AttemptedCount);
Assert.Equal(2, attempt.CompletedCount);
ResourceReleaseFailure failure = Assert.Single(attempt.Failures);
Assert.Equal("committed", failure.Stage);
Assert.True(failure.MutationCommitted);
Assert.Equal(1, committedCalls);
Assert.Equal(1, laterCalls);
ResourceReleaseAttempt duplicate = ledger.Advance();
Assert.Equal(0, duplicate.AttemptedCount);
Assert.Empty(duplicate.Failures);
Assert.Equal(1, committedCalls);
Assert.Equal(1, laterCalls);
}
[Fact]
public void Advance_MultipleIndependentFailures_AllRunAndConvergeIndependently()
{
int firstFailures = 0;
int secondFailures = 0;
int successfulTail = 0;
var ledger = new RetryableResourceReleaseLedger(
[
("first", () =>
{
if (firstFailures++ == 0)
throw new InvalidOperationException("first");
}),
("second", () =>
{
if (secondFailures++ < 2)
throw new InvalidOperationException("second");
}),
("tail", () => successfulTail++),
]);
ResourceReleaseAttempt first = ledger.Advance();
ResourceReleaseAttempt second = ledger.Advance();
ResourceReleaseAttempt third = ledger.Advance();
Assert.Equal(2, first.Failures.Count);
Assert.Single(second.Failures);
Assert.Empty(third.Failures);
Assert.True(ledger.IsComplete);
Assert.Equal(1, successfulTail);
Assert.Equal(2, firstFailures);
Assert.Equal(3, secondFailures);
}
[Fact]
public void Advance_ReentrantAttempt_DoesNotExecuteActiveStageTwice()
{
RetryableResourceReleaseLedger? ledger = null;
int activeCalls = 0;
int tailCalls = 0;
ledger = new RetryableResourceReleaseLedger(
[
("active", () =>
{
activeCalls++;
ledger!.Advance();
}),
("tail", () => tailCalls++),
]);
ResourceReleaseAttempt attempt = ledger.Advance();
Assert.True(ledger.IsComplete);
Assert.Empty(attempt.Failures);
Assert.Equal(1, activeCalls);
Assert.Equal(1, tailCalls);
}
[Fact]
public void Advance_ReentrantAttemptDoesNotGiveFailedTailTwoAttemptsInOnePass()
{
RetryableResourceReleaseLedger? ledger = null;
int activeCalls = 0;
int flakyTailCalls = 0;
ledger = new RetryableResourceReleaseLedger(
[
("active", () =>
{
activeCalls++;
ledger!.Advance();
}),
("flaky-tail", () =>
{
flakyTailCalls++;
if (flakyTailCalls == 1)
throw new InvalidOperationException("first pass");
}),
]);
ResourceReleaseAttempt first = ledger.Advance();
Assert.Single(first.Failures);
Assert.Equal(1, activeCalls);
Assert.Equal(1, flakyTailCalls);
Assert.False(ledger.IsComplete);
ResourceReleaseAttempt second = ledger.Advance();
Assert.Empty(second.Failures);
Assert.True(ledger.IsComplete);
Assert.Equal(2, flakyTailCalls);
}
}

View file

@ -0,0 +1,76 @@
using AcDream.App.Rendering.Wb;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class TextureAtlasCapacityTests
{
[Theory]
[InlineData(128, 128, 32)]
[InlineData(256, 256, 24)]
[InlineData(512, 512, 6)]
[InlineData(1024, 1024, 1)]
public void RgbaCapacityTargetsEightMiBIncludingMipChain(
int width,
int height,
int expected)
{
Assert.Equal(
expected,
TextureAtlasManager.CalculateInitialCapacity(width, height, TextureFormat.RGBA8));
}
[Fact]
public void SmallCompressedLayersRemainCountCapped()
{
Assert.Equal(
TextureAtlasManager.MaximumArrayLayers,
TextureAtlasManager.CalculateInitialCapacity(32, 32, TextureFormat.DXT1));
}
[Fact]
public void DirectUploadAcceptsExactRgbaPayload()
{
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.UnsignedByte);
}
[Theory]
[InlineData(15)]
[InlineData(17)]
public void DirectUploadRejectsNonExactPayloadLength(int bytes)
{
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
}
[Fact]
public void DirectUploadRejectsMismatchedTransferTuple()
{
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgb, PixelType.UnsignedByte));
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.Float));
}
[Fact]
public void CompressedUploadRejectsUncompressedDescriptorOverride()
{
int bytes = ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
Assert.Throws<ArgumentException>(() =>
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.DXT1, 4, 4, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
}
[Fact]
public void RgbPayloadUsesTightlyPackedRows()
{
Assert.Equal(18, ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
ManagedGLTextureArray.ValidateUploadPayload(
TextureFormat.RGB8, 3, 2, 18, PixelFormat.Rgb, PixelType.UnsignedByte);
}
}

View file

@ -0,0 +1,162 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class TextureAtlasSlotAllocatorTests
{
[Fact]
public void SlotIsNotAvailableUntilGpuRetirementReturnsIt()
{
var slots = new TextureAtlasSlotAllocator(capacity: 2);
int first = slots.Rent();
int second = slots.Rent();
Assert.Equal(0, slots.AvailableCount);
Assert.Throws<InvalidOperationException>(() => slots.Rent());
// Dropping the texture's CPU key does not call Return. The layer is
// still unavailable while an older draw may be sampling it.
Assert.Equal(0, slots.AvailableCount);
slots.Return(first);
Assert.Equal(1, slots.AvailableCount);
Assert.Equal(first, slots.Rent());
Assert.Equal(0, slots.AvailableCount);
Assert.NotEqual(first, second);
}
[Fact]
public void DoubleReturnIsRejected()
{
var slots = new TextureAtlasSlotAllocator(capacity: 1);
int slot = slots.Rent();
slots.Return(slot);
Assert.Throws<InvalidOperationException>(() => slots.Return(slot));
}
[Fact]
public void LayerRetirement_PublicationFailureRetainsPhysicalReturn()
{
var queue = new DeferredQueue { FailNextPublication = true };
var retirement = new TextureAtlasLayerRetirement(queue);
int returns = 0;
int notifications = 0;
Assert.Throws<InvalidOperationException>(() =>
retirement.Retire(() => returns++, () => notifications++));
Assert.Equal(1, retirement.AwaitingPublicationCount);
Assert.Equal(0, returns);
retirement.RetryPendingPublications();
Assert.Equal(0, retirement.AwaitingPublicationCount);
queue.Actions.Single()();
Assert.Equal(1, returns);
Assert.Equal(1, notifications);
}
[Fact]
public void LayerRetirement_ObserverFailureNeverReturnsSlotTwice()
{
var queue = new DeferredQueue();
var retirement = new TextureAtlasLayerRetirement(queue);
int returns = 0;
int notifications = 0;
retirement.Retire(
() => returns++,
() =>
{
notifications++;
if (notifications == 1)
throw new InvalidOperationException("observer");
});
Action callback = queue.Actions.Single();
Assert.Throws<InvalidOperationException>(callback);
callback();
Assert.Equal(1, returns);
Assert.Equal(2, notifications);
}
[Fact]
public void DisposeTransaction_ArrayPublicationFailureRetainsRetryableOwnership()
{
var transaction = new TextureAtlasDisposeTransaction();
int layerRetries = 0;
int arrayDisposals = 0;
int logicalCommits = 0;
bool failFirstArrayDisposal = true;
Assert.Throws<InvalidOperationException>(() => transaction.Advance(
() => layerRetries++,
() => {
arrayDisposals++;
if (failFirstArrayDisposal) {
failFirstArrayDisposal = false;
throw new InvalidOperationException("injected array publication failure");
}
},
() => logicalCommits++));
Assert.False(transaction.IsComplete);
Assert.Equal(1, layerRetries);
Assert.Equal(1, arrayDisposals);
Assert.Equal(0, logicalCommits);
transaction.Advance(
() => layerRetries++,
() => arrayDisposals++,
() => logicalCommits++);
transaction.Advance(
() => layerRetries++,
() => arrayDisposals++,
() => logicalCommits++);
Assert.True(transaction.IsComplete);
Assert.Equal(2, layerRetries);
Assert.Equal(2, arrayDisposals);
Assert.Equal(1, logicalCommits);
}
[Fact]
public void DisposeTransaction_ReentrantAdvanceDoesNotReplayActiveStage()
{
var transaction = new TextureAtlasDisposeTransaction();
int layerRetries = 0;
int arrayDisposals = 0;
int logicalCommits = 0;
Action retryLayers = () => layerRetries++;
Action commit = () => logicalCommits++;
Action? disposeArray = null;
disposeArray = () => {
arrayDisposals++;
transaction.Advance(retryLayers, disposeArray!, commit);
};
transaction.Advance(retryLayers, disposeArray, commit);
Assert.True(transaction.IsComplete);
Assert.Equal(1, layerRetries);
Assert.Equal(1, arrayDisposals);
Assert.Equal(1, logicalCommits);
}
private sealed class DeferredQueue : IGpuResourceRetirementQueue
{
public bool FailNextPublication { get; set; }
public List<Action> Actions { get; } = [];
public void Retire(Action release)
{
if (FailNextPublication)
{
FailNextPublication = false;
throw new InvalidOperationException("publication");
}
Actions.Add(release);
}
}
}

View file

@ -0,0 +1,112 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class WbDrawDispatcherCompositeWarmupTests
{
private const uint Destination = 0x1234FFFFu;
[Fact]
public void NewlyPublishedEntitySnapshotRequiresRebuildAfterPriorSnapshotWasReady()
{
IReadOnlyList<WorldEntity> emptyReadySnapshot = Array.Empty<WorldEntity>();
IReadOnlyList<WorldEntity> publishedDestinationSnapshot =
[CreateEntity(parentCell: Destination, withPalette: true)];
Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
emptyReadySnapshot,
Destination,
currentRadius: 0,
currentGeneration: 0,
publishedDestinationSnapshot,
nextGeneration: 1,
Destination,
nextRadius: 0));
}
[Fact]
public void MutatedStableEntityViewRequiresRebuildWhenGenerationChanges()
{
IReadOnlyList<WorldEntity> stableView = new List<WorldEntity>();
Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
stableView,
Destination,
currentRadius: 0,
currentGeneration: 41,
stableView,
nextGeneration: 42,
Destination,
nextRadius: 0));
}
[Fact]
public void PaletteEntityInsideDestinationRadiusIsCandidate()
{
WorldEntity entity = CreateEntity(parentCell: 0x1235FFFEu, withPalette: true);
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
}
[Fact]
public void EntityOutsideDestinationRadiusIsExcluded()
{
WorldEntity entity = CreateEntity(parentCell: 0x1236FFFEu, withPalette: true);
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
}
[Fact]
public void CellLessEntityIsExcludedFromKnownDestination()
{
WorldEntity entity = CreateEntity(parentCell: null, withPalette: true);
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
}
[Fact]
public void SameLandblockIndoorCellIsCandidateAtZeroRadius()
{
WorldEntity entity = CreateEntity(parentCell: 0x1234012Au, withPalette: true);
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
}
[Fact]
public void EntityWithoutPaletteOrSurfaceOverridesIsExcluded()
{
WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false);
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
}
[Fact]
public void SurfaceOverrideEntityIsCandidateWithoutPalette()
{
WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false);
entity.MeshRefs =
[
new MeshRef(0x01000001u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint> { [0x08000001u] = 0x05000001u },
},
];
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
}
private static WorldEntity CreateEntity(uint? parentCell, bool withPalette) => new()
{
Id = 1,
SourceGfxObjOrSetupId = 0x01000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(0x01000001u, Matrix4x4.Identity)],
ParentCellId = parentCell,
PaletteOverride = withPalette
? new PaletteOverride(0x04000001u, Array.Empty<PaletteOverride.SubPaletteRange>())
: null,
};
}

View file

@ -0,0 +1,25 @@
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class WbTextureResolutionPolicyTests
{
[Theory]
[InlineData(false, false, false, (int)WbTextureResolutionKind.SharedAtlas)]
[InlineData(false, true, false, (int)WbTextureResolutionKind.SharedAtlas)]
[InlineData(true, false, false, (int)WbTextureResolutionKind.OriginalTextureOverride)]
[InlineData(true, true, false, (int)WbTextureResolutionKind.OriginalTextureOverride)]
[InlineData(false, true, true, (int)WbTextureResolutionKind.PaletteComposite)]
[InlineData(true, true, true, (int)WbTextureResolutionKind.PaletteComposite)]
public void Select_MatchesRetailImageOwnership(
bool hasOriginalTextureOverride,
bool hasPaletteOverride,
bool sourceIsPaletteIndexed,
int expected)
{
Assert.Equal((WbTextureResolutionKind)expected, WbTextureResolutionPolicy.Select(
hasOriginalTextureOverride,
hasPaletteOverride,
sourceIsPaletteIndexed));
}
}