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:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue