fix(rendering): bound portal resource lifetime

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

View file

@ -0,0 +1,100 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class BoundedUnownedResourceCacheTests
{
[Fact]
public void ReacquireRemovesResourceFromBudgetAndEvictionOrder()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 10);
cache.MarkUnowned("first", 6);
cache.MarkUnowned("second", 6);
Assert.True(cache.MarkOwned("first"));
Assert.Equal(1, cache.Count);
Assert.Equal(6, cache.ResidentBytes);
Assert.False(cache.TryTakeOldestOverBudget(out _));
}
[Fact]
public void TakesOnlyOldestResourceWhileOverBudget()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 10);
cache.MarkUnowned("first", 6);
cache.MarkUnowned("second", 6);
cache.MarkUnowned("third", 6);
Assert.True(cache.TryTakeOldestOverBudget(out string first));
Assert.Equal("first", first);
Assert.Equal(12, cache.ResidentBytes);
Assert.True(cache.TryTakeOldestOverBudget(out string second));
Assert.Equal("second", second);
Assert.Equal(6, cache.ResidentBytes);
Assert.False(cache.TryTakeOldestOverBudget(out _));
}
[Fact]
public void RemarkingUnownedRefreshesLruPositionWithoutDoubleCounting()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 5);
cache.MarkUnowned("first", 4);
cache.MarkUnowned("second", 4);
cache.MarkUnowned("first", 4);
Assert.Equal(2, cache.Count);
Assert.Equal(8, cache.ResidentBytes);
Assert.True(cache.TryTakeOldestOverBudget(out string key));
Assert.Equal("second", key);
}
[Fact]
public void SizeMutationForSameKeyIsRejected()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 100);
cache.MarkUnowned("key", 4);
Assert.Throws<InvalidOperationException>(() => cache.MarkUnowned("key", 8));
}
[Fact]
public void PhysicalBudgetCanTakeOldestEvenBelowLogicalBudget()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 100);
cache.MarkUnowned("old", 4);
cache.MarkUnowned("new", 4);
Assert.False(cache.TryTakeOldestOverBudget(out _));
Assert.True(cache.TryTakeOldest(out string key));
Assert.Equal("old", key);
Assert.Equal(4, cache.ResidentBytes);
}
[Fact]
public void ExactByteAndCountLimitsRemainRetained()
{
var cache = new BoundedUnownedResourceCache<int>(budgetBytes: 64, maximumCount: 2);
cache.MarkUnowned(1, 32);
cache.MarkUnowned(2, 32);
Assert.False(cache.TryTakeOldestOverBudget(out _));
Assert.Equal(2, cache.Count);
Assert.Equal(64, cache.ResidentBytes);
}
[Fact]
public void CountOverflowTakesOnlyOneOldestResource()
{
var cache = new BoundedUnownedResourceCache<string>(budgetBytes: 1_000, maximumCount: 2);
cache.MarkUnowned("oldest", 1);
cache.MarkUnowned("middle", 1);
cache.MarkUnowned("newest", 1);
Assert.True(cache.TryTakeOldestOverBudget(out string victim));
Assert.Equal("oldest", victim);
Assert.Equal(2, cache.Count);
Assert.False(cache.TryTakeOldestOverBudget(out _));
}
}

View file

@ -0,0 +1,252 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public sealed class BuildingGroupScratchTests
{
[Fact]
public void Rebuild_DropsHistoricalKeys_BoundsRetention_AndPreservesEncounterOrder()
{
var scratch = new BuildingGroupScratch();
int pathologicalCount = BuildingGroupScratch.MaxRetainedGroups * 4;
var pathological = new List<LoadedCell>(pathologicalCount);
for (int i = 0; i < pathologicalCount; i++)
{
pathological.Add(new LoadedCell
{
CellId = (uint)i + 1u,
BuildingId = 0x1000_0000u + (uint)i,
});
}
scratch.Rebuild(pathological);
Assert.Equal(pathologicalCount, scratch.ActiveGroupCount);
Assert.True(scratch.MapCapacity > BuildingGroupScratch.MaxRetainedGroups);
var firstA = new LoadedCell { CellId = 0xAA01u, BuildingId = 0x20u };
var second = new LoadedCell { CellId = 0xBB01u, BuildingId = 0x10u };
var firstB = new LoadedCell { CellId = 0xAA02u, BuildingId = 0x20u };
var unstamped = new LoadedCell { CellId = 0xCC01u };
scratch.Rebuild([firstA, second, firstB, unstamped]);
Assert.Equal(3, scratch.ActiveGroupCount);
Assert.Equal(
new uint[] { 0x20u, 0x10u, unstamped.CellId },
scratch.Groups.Keys.ToArray());
Assert.Equal(new[] { firstA, firstB }, scratch.Groups[0x20u]);
Assert.Equal(new[] { second }, scratch.Groups[0x10u]);
Assert.Equal(new[] { unstamped }, scratch.Groups[unstamped.CellId]);
Assert.DoesNotContain(
scratch.Groups.Keys,
key => key >= 0x1000_0000u);
Assert.InRange(
scratch.RetainedListCount,
0,
BuildingGroupScratch.MaxRetainedGroups);
Assert.InRange(
scratch.MapCapacity,
0,
BuildingGroupScratch.MaxRetainedGroups);
scratch.Reset();
Assert.Equal(0, scratch.ActiveGroupCount);
Assert.Empty(scratch.Groups);
Assert.InRange(
scratch.RetainedListCount,
0,
BuildingGroupScratch.MaxRetainedGroups);
Assert.InRange(
scratch.MapCapacity,
0,
BuildingGroupScratch.MaxRetainedGroups);
}
[Fact]
public void Reset_DoesNotRetainPathologicalGroupBackingArray()
{
var scratch = new BuildingGroupScratch();
var oversizedGroup = new List<LoadedCell>(
BuildingGroupScratch.MaxRetainedCellsPerGroup * 2);
for (int i = 0;
i < BuildingGroupScratch.MaxRetainedCellsPerGroup * 2;
i++)
{
oversizedGroup.Add(new LoadedCell
{
CellId = (uint)i + 1u,
BuildingId = 0x42u,
});
}
scratch.Rebuild(oversizedGroup);
Assert.Single(scratch.Groups);
Assert.True(
scratch.Groups[0x42u].Capacity
> BuildingGroupScratch.MaxRetainedCellsPerGroup);
scratch.Reset();
Assert.Equal(0, scratch.RetainedListCount);
Assert.Empty(scratch.Groups);
}
}
public sealed class RetailPViewScratchRetentionTests
{
[Fact]
public void ClearFrameBuffers_DropsPathologicalHighWater_AfterIdleHysteresis()
{
var retention = new RetailPViewScratchRetention();
int pathologicalCellCount =
RetailPViewScratchRetention.MaxRetainedCellItems * 4;
int pathologicalFrameCount =
RetailPViewScratchRetention.MaxRetainedLookInFrames * 4;
var lookInFrames = new List<PortalVisibilityFrame>(pathologicalFrameCount);
var lookInPrepare = new HashSet<uint>();
var drawableCells = new HashSet<uint>();
var shellBatch = new HashSet<uint>();
var orderedTransparent = new List<uint>(pathologicalCellCount);
for (int i = 0; i < pathologicalFrameCount; i++)
lookInFrames.Add(new PortalVisibilityFrame());
for (int i = 0; i < pathologicalCellCount; i++)
{
uint id = (uint)i;
lookInPrepare.Add(id);
drawableCells.Add(id);
shellBatch.Add(id);
orderedTransparent.Add(id);
}
Assert.True(
lookInFrames.Capacity
> RetailPViewScratchRetention.MaxRetainedLookInFrames);
Assert.True(
drawableCells.EnsureCapacity(0)
> RetailPViewScratchRetention.MaxRetainedCellItems);
retention.ClearFrameBuffers(
lookInFrames,
lookInPrepare,
drawableCells,
shellBatch,
orderedTransparent);
Assert.Empty(lookInFrames);
Assert.Empty(lookInPrepare);
Assert.Empty(drawableCells);
Assert.Empty(shellBatch);
Assert.Empty(orderedTransparent);
Assert.True(
lookInFrames.Capacity
> RetailPViewScratchRetention.MaxRetainedLookInFrames);
for (int i = 0; i < RetailPViewScratchRetention.CapacityTrimIdleFrames; i++)
{
retention.ClearFrameBuffers(
lookInFrames,
lookInPrepare,
drawableCells,
shellBatch,
orderedTransparent);
}
Assert.InRange(
lookInFrames.Capacity,
0,
RetailPViewScratchRetention.MaxRetainedLookInFrames);
Assert.InRange(
lookInPrepare.EnsureCapacity(0),
0,
RetailPViewScratchRetention.MaxRetainedCellItems);
Assert.InRange(
drawableCells.EnsureCapacity(0),
0,
RetailPViewScratchRetention.MaxRetainedCellItems);
Assert.InRange(
shellBatch.EnsureCapacity(0),
0,
RetailPViewScratchRetention.MaxRetainedCellItems);
Assert.InRange(
orderedTransparent.Capacity,
0,
RetailPViewScratchRetention.MaxRetainedCellItems);
}
[Fact]
public void ClearFrameBuffers_PreservesNormalWarmCapacity()
{
var retention = new RetailPViewScratchRetention();
var lookInFrames = new List<PortalVisibilityFrame>(8);
var lookInPrepare = new HashSet<uint>();
var drawableCells = new HashSet<uint>();
var shellBatch = new HashSet<uint>();
var orderedTransparent = new List<uint>(64);
for (uint id = 0; id < 64; id++)
{
lookInPrepare.Add(id);
drawableCells.Add(id);
shellBatch.Add(id);
orderedTransparent.Add(id);
}
int lookInCapacity = lookInFrames.Capacity;
int prepareCapacity = lookInPrepare.EnsureCapacity(0);
int drawableCapacity = drawableCells.EnsureCapacity(0);
int shellCapacity = shellBatch.EnsureCapacity(0);
int transparentCapacity = orderedTransparent.Capacity;
retention.ClearFrameBuffers(
lookInFrames,
lookInPrepare,
drawableCells,
shellBatch,
orderedTransparent);
Assert.Equal(lookInCapacity, lookInFrames.Capacity);
Assert.Equal(prepareCapacity, lookInPrepare.EnsureCapacity(0));
Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0));
Assert.Equal(shellCapacity, shellBatch.EnsureCapacity(0));
Assert.Equal(transparentCapacity, orderedTransparent.Capacity);
}
[Fact]
public void ClearFrameBuffers_RecurringLargeWorkingSet_PreservesWarmCapacity()
{
const int recurringCount =
RetailPViewScratchRetention.MaxRetainedCellItems + 163;
var retention = new RetailPViewScratchRetention();
var lookInFrames = new List<PortalVisibilityFrame>();
var lookInPrepare = new HashSet<uint>();
var drawableCells = new HashSet<uint>();
var shellBatch = new HashSet<uint>();
var orderedTransparent = new List<uint>();
for (int iteration = 0;
iteration < RetailPViewScratchRetention.CapacityTrimIdleFrames + 5;
iteration++)
{
for (int i = 0; i < recurringCount; i++)
{
uint id = (uint)i;
drawableCells.Add(id);
orderedTransparent.Add(id);
}
int drawableCapacity = drawableCells.EnsureCapacity(0);
int transparentCapacity = orderedTransparent.Capacity;
retention.ClearFrameBuffers(
lookInFrames,
lookInPrepare,
drawableCells,
shellBatch,
orderedTransparent);
Assert.Equal(drawableCapacity, drawableCells.EnsureCapacity(0));
Assert.Equal(transparentCapacity, orderedTransparent.Capacity);
}
}
}

View file

@ -61,4 +61,44 @@ public class CellViewDedupTests
Assert.True(v.Add(Quad(0.4f, 0f)));
Assert.Equal(2, v.Polygons.Count);
}
[Fact]
public void Add_RepeatedDuplicate_DoesNotAllocatePerProbe()
{
var view = new CellView();
ViewPolygon polygon = Quad(0f, 0f);
Assert.True(view.Add(polygon));
// Warm the method/JIT before measuring. Duplicate portal emissions are the hot path.
Assert.False(view.Add(polygon));
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
Assert.False(view.Add(polygon));
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(allocated <= 256, $"duplicate probes allocated {allocated:N0} bytes");
}
[Fact]
public void ResetAndRebuild_ReusesCanonicalKeyStorage()
{
var view = new CellView();
ViewPolygon polygon = Quad(0f, 0f);
// Warm the retained polygon list, hash table, collision link, and
// coordinate payload before measuring frame-to-frame rebuilds.
Assert.True(view.Add(polygon));
view.Reset();
Assert.True(view.Add(polygon));
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1_000; i++)
{
view.Reset();
Assert.True(view.Add(polygon));
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(allocated <= 256, $"steady rebuilds allocated {allocated:N0} bytes");
}
}

View file

@ -206,4 +206,97 @@ public class ClipFrameAssemblerTests
Assert.False(asm2.OutdoorVisible);
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
}
[Fact]
public void Assemble_ReusedAssembly_ClearsStateAndReusesExactLengthArrays()
{
const uint firstCell = 0xA9B40100;
const uint secondCell = 0xA9B40200;
var frame = ClipFrame.NoClip();
var reuse = new ClipFrameAssembly();
var first = new PortalVisibilityFrame();
first.CellViews[firstCell] = ViewOf(
Square(-0.4f, 0f, 0.15f),
Square(0.4f, 0f, 0.15f));
first.OrderedVisibleCells.Add(firstCell);
first.OutsideView.Add(Square(0f, 0.5f, 0.2f));
ClipFrameAssembly firstResult = ClipFrameAssembler.Assemble(frame, first, reuse);
Assert.Same(reuse, firstResult);
int sliceAllocations = reuse.SliceArrayAllocationCount;
int slotAllocations = reuse.SlotArrayAllocationCount;
var second = new PortalVisibilityFrame();
second.CellViews[secondCell] = ViewOf(
Square(-0.4f, 0f, 0.15f),
Square(0.4f, 0f, 0.15f));
second.OrderedVisibleCells.Add(secondCell);
second.OutsideView.Add(Square(0f, 0.5f, 0.2f));
for (int i = 0; i < 12; i++)
{
ClipFrameAssembly actual = ClipFrameAssembler.Assemble(frame, second, reuse);
Assert.Same(reuse, actual);
Assert.DoesNotContain(firstCell, actual.CellIdToViewSlices.Keys);
Assert.Contains(secondCell, actual.CellIdToViewSlices.Keys);
Assert.Equal(2, actual.CellIdToViewSlices[secondCell].Length);
Assert.Equal(2, actual.CellIdToViewSlots[secondCell].Length);
Assert.Single(actual.OutsideViewSlices);
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
}
var empty = new PortalVisibilityFrame();
ClipFrameAssembly cleared = ClipFrameAssembler.Assemble(frame, empty, reuse);
Assert.Empty(cleared.CellIdToSlot);
Assert.Empty(cleared.CellIdToViewSlots);
Assert.Empty(cleared.CellIdToViewSlices);
Assert.Empty(cleared.PerCellPlaneCounts);
Assert.Empty(cleared.OutsideViewSlices);
Assert.Equal(TerrainClipMode.Skip, cleared.TerrainMode);
}
[Fact]
public void Assemble_ReusedAssembly_ChangingCardinalityStaysBoundedThenReachesSteadyState()
{
const uint cellId = 0xA9B40100;
var frame = ClipFrame.NoClip();
var reuse = new ClipFrameAssembly();
static PortalVisibilityFrame FrameWithSlices(uint id, int count)
{
var portalFrame = new PortalVisibilityFrame();
var view = new CellView();
for (int i = 0; i < count; i++)
{
// The assembler consumes the retail view_poly list directly. Distinct placement is
// irrelevant to this cache-cardinality stress; avoid CellView dedup collapsing it.
view.Polygons.Add(Square(i * 0.001f, 0f, 0.0004f));
}
portalFrame.CellViews[id] = view;
portalFrame.OrderedVisibleCells.Add(id);
return portalFrame;
}
for (int count = 1; count <= 180; count++)
ClipFrameAssembler.Assemble(frame, FrameWithSlices(cellId, count), reuse);
// Flush the final live arrays into the cache too.
ClipFrameAssembler.Assemble(frame, new PortalVisibilityFrame(), reuse);
Assert.InRange(reuse.RetainedSliceItems, 0, ClipFrameAssembly.MaxRetainedSliceItems);
Assert.InRange(reuse.RetainedSlotItems, 0, ClipFrameAssembly.MaxRetainedSlotItems);
Assert.InRange(reuse.RetainedSliceArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
Assert.InRange(reuse.RetainedSlotArrays, 0, ClipFrameAssembly.MaxRetainedArraysPerPool);
PortalVisibilityFrame steady = FrameWithSlices(cellId, 73);
ClipFrameAssembler.Assemble(frame, steady, reuse);
int sliceAllocations = reuse.SliceArrayAllocationCount;
int slotAllocations = reuse.SlotArrayAllocationCount;
for (int i = 0; i < 16; i++)
{
ClipFrameAssembler.Assemble(frame, steady, reuse);
Assert.Equal(sliceAllocations, reuse.SliceArrayAllocationCount);
Assert.Equal(slotAllocations, reuse.SlotArrayAllocationCount);
}
}
}

View file

@ -0,0 +1,130 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public sealed class ClipFrameUploadTests
{
[Theory]
[InlineData(1, 144)]
[InlineData(16, 144)]
[InlineData(64, 192)]
[InlineData(256, 256)]
[InlineData(512, 512)]
public void TerrainArena_RecordStride_RespectsDriverAlignment(
int alignment,
int expectedStride)
{
Assert.Equal(expectedStride, ClipFrameArenaLayout.RecordStride(alignment));
}
[Fact]
public void TerrainArena_AssignsEverySliceAUniqueOrderedRange()
{
const int stride = 256;
Assert.Equal(0, ClipFrameArenaLayout.RecordOffset(0, stride));
Assert.Equal(256, ClipFrameArenaLayout.RecordOffset(1, stride));
Assert.Equal(512, ClipFrameArenaLayout.RecordOffset(2, stride));
Assert.Equal(1280, ClipFrameArenaLayout.RequiredBytes(5, stride));
}
[Fact]
public void UploadState_RegionsOnce_AndTerrainRangesInSubmissionOrder()
{
var state = new ClipFrameUploadState();
state.BeginFrame();
state.ValidateRegionsNotUploaded();
state.MarkRegionsUploaded();
Assert.True(state.RegionsUploaded);
Assert.Throws<InvalidOperationException>(state.ValidateRegionsNotUploaded);
state.ValidateTerrainReservation(4);
state.CommitTerrainReservation(4);
Assert.Equal(new[] { 0, 1, 2, 3 }, new[]
{
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
state.NextTerrainRecord(),
});
Assert.Equal(4, state.TerrainUploaded);
Assert.Throws<InvalidOperationException>(() => state.NextTerrainRecord());
state.BeginFrame();
Assert.False(state.RegionsUploaded);
Assert.Equal(0, state.TerrainReserved);
Assert.Equal(0, state.TerrainUploaded);
}
[Fact]
public void ResourceRing_RetainsAtMostOneResourcePerFencedFrameSlot()
{
var ring = new ClipFrameResourceRing<object>(ClipFrame.FrameSlotCount);
for (int slot = 0; slot < ClipFrame.FrameSlotCount; slot++)
ring.Set(slot, new object());
// Pathological slice counts reuse ranges in the frame slot's one arena;
// they cannot add another GL object to the ring.
for (int slice = 0; slice < 10_000; slice++)
Assert.True(ring.TryGet(slice % ClipFrame.FrameSlotCount, out _));
Assert.Equal(ClipFrame.FrameSlotCount, ring.Count);
Assert.Throws<InvalidOperationException>(() => ring.Set(0, new object()));
}
[Fact]
public void CapacityPolicy_ShrinksOneOffPathologicalPeakAfterHysteresis()
{
var policy = new ClipBufferCapacityPolicy();
int peak = policy.SelectCapacity(0, 1_000_000);
Assert.True(peak >= 1_000_000);
int first = policy.SelectCapacity(peak, 4_096);
int second = policy.SelectCapacity(first, 4_096);
int third = policy.SelectCapacity(second, 4_096);
Assert.Equal(peak, first);
Assert.Equal(peak, second);
Assert.Equal(4_096, third);
}
[Fact]
public void CapacityPolicy_OrdinaryDemandJitterCancelsPendingShrink()
{
var policy = new ClipBufferCapacityPolicy();
int capacity = policy.SelectCapacity(0, 65_536);
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 20_000); // above 25% utilization
capacity = policy.SelectCapacity(capacity, 4_096);
capacity = policy.SelectCapacity(capacity, 4_096);
Assert.Equal(65_536, capacity);
}
[Fact]
public void CapacityTransaction_PublishesOnlySuccessfulResize()
{
int capacity = 4_096;
Assert.Throws<InvalidOperationException>(() =>
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(_, _) => throw new InvalidOperationException("BufferData failed")));
Assert.Equal(4_096, capacity);
ClipBufferCapacityTransaction.Resize(
ref capacity,
8_192,
(previous, next) =>
{
Assert.Equal(4_096, previous);
Assert.Equal(8_192, next);
});
Assert.Equal(8_192, capacity);
// A later stage failure must not roll accounting back to the old store.
Action laterFailure = () => throw new InvalidOperationException("later bind failed");
Assert.Throws<InvalidOperationException>(laterFailure);
Assert.Equal(8_192, capacity);
}
}

View file

@ -32,6 +32,27 @@ public class ClipPlaneSetTests
return cv;
}
[Fact]
public void From_ViewPolygon_MatchesSinglePolygonCellView()
{
var polygon = new ViewPolygon(new[]
{
new Vector2(-0.7f, -0.4f), new Vector2(0.2f, -0.4f),
new Vector2(0.7f, 0.3f), new Vector2(-0.5f, 0.6f),
});
var view = new CellView();
Assert.True(view.Add(polygon));
var fromView = ClipPlaneSet.From(view);
var fromPolygon = ClipPlaneSet.From(polygon);
Assert.Equal(fromView.Count, fromPolygon.Count);
Assert.Equal(fromView.UseScissorFallback, fromPolygon.UseScissorFallback);
Assert.Equal(fromView.IsNothingVisible, fromPolygon.IsNothingVisible);
Assert.Equal(fromView.ScissorNdcAabb, fromPolygon.ScissorNdcAabb);
Assert.Equal(fromView.Planes, fromPolygon.Planes);
}
// --- The three required tests (verbatim intent from the plan) -------------
[Fact]

View file

@ -0,0 +1,609 @@
using AcDream.App.Rendering;
using AcDream.Core.Textures;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class CompositeTextureArrayCachePolicyTests
{
[Theory]
[InlineData(16, 16, 64)]
[InlineData(128, 128, 64)]
[InlineData(256, 256, 16)]
[InlineData(512, 512, 4)]
[InlineData(1024, 1024, 1)]
public void CapacityTargetsFourMiBWithoutOversizingLargeLayers(
int width,
int height,
int expected)
{
Assert.Equal(
expected,
CompositeTextureArrayCache.CalculateLayerCapacity(width, height, driverMaximumLayers: 2048));
}
[Fact]
public void CapacityHonorsDriverLayerLimit()
{
Assert.Equal(
8,
CompositeTextureArrayCache.CalculateLayerCapacity(16, 16, driverMaximumLayers: 8));
}
[Fact]
public void CompatibleTexturesShareArrayAndOwnersShareExactSlice()
{
var backend = new FakeBackend(maximumLayers: 2);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(backend, retirements);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation first));
Assert.True(cache.TryAcquire(2, Key(1), out BindlessTextureLocation shared));
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out BindlessTextureLocation second));
Assert.Equal(first, shared);
Assert.Equal(first.Handle, second.Handle);
Assert.NotEqual(first.Layer, second.Layer);
Assert.Single(backend.Created);
Assert.Equal(2, backend.Uploads.Count);
Assert.Equal(2, cache.ActiveResourceCount);
}
[Fact]
public void FinalReleaseCachesAndReacquireAvoidsUpload()
{
var backend = new FakeBackend(maximumLayers: 2);
using var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation original));
Assert.True(cache.TryAcquire(2, Key(1), out _));
cache.ReleaseOwner(1);
Assert.Equal(0, cache.UnownedEntryCount);
cache.ReleaseOwner(2);
Assert.Equal(1, cache.UnownedEntryCount);
Assert.Empty(backend.NonResident);
Assert.Empty(backend.Deleted);
Assert.True(cache.TryAcquire(3, Key(1), out BindlessTextureLocation reacquired));
Assert.Equal(original, reacquired);
Assert.Single(backend.Uploads);
Assert.Equal(0, cache.UnownedEntryCount);
}
[Fact]
public void EvictedLayerCannotBeReusedBeforeFenceAndOldCallbackCannotTouchReplacement()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: long.MaxValue);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation old));
cache.ReleaseOwner(1);
cache.Tick();
Assert.Equal(1, retirements.Count);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(2, Key(1), Texture(4, 4), out BindlessTextureLocation replacement));
Assert.NotEqual(old.Handle, replacement.Handle);
Assert.Equal(2, backend.Created.Count);
retirements.DrainAll();
Assert.True(cache.TryAcquire(3, Key(1), out BindlessTextureLocation stillReplacement));
Assert.Equal(replacement, stillReplacement);
}
[Fact]
public void MaintenanceBatchesLogicalEvictionButDeletesOnlyOneArrayPerTick()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _));
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(3), Texture(4, 4), out _));
cache.ReleaseOwner(1);
cache.Tick();
Assert.Equal(0, cache.CachedEntryCount);
Assert.Equal(3, retirements.Count);
Assert.Empty(backend.Deleted);
retirements.DrainAll();
cache.Tick();
Assert.Equal(0, cache.CachedEntryCount);
Assert.Single(backend.Deleted);
Assert.Equal(
["nonresident:1", "delete:1"],
backend.Events.TakeLast(2));
cache.Tick();
Assert.Equal(2, backend.Deleted.Count);
}
[Fact]
public void UploadFailureRollsBackAndDeletesNewEmptyArray()
{
var backend = new FakeBackend(maximumLayers: 2) { FailNextUpload = true };
using var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.Throws<InvalidOperationException>(
() => cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
Assert.Equal(0, cache.CachedEntryCount);
Assert.Equal(0, cache.AtlasCount);
Assert.Equal(0, cache.AllocatedBytes);
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
[Fact]
public void UploadAndRollbackFailureRetainsEmptyAtlasForMaintenanceRetry()
{
var backend = new FakeBackend(maximumLayers: 2)
{
FailNextUpload = true,
FailNextNonResident = true,
};
using var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.Throws<AggregateException>(
() => cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
Assert.Equal(128, cache.AllocatedBytes);
Assert.Equal(1, cache.AtlasCount);
cache.Tick();
Assert.Equal(0, cache.AllocatedBytes);
Assert.Equal(0, cache.AtlasCount);
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
[Fact]
public void DisposeMakesEveryHandleNonResidentBeforeDeletingAnyArray()
{
var backend = new FakeBackend(maximumLayers: 1);
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(8, 8), out _));
cache.Dispose();
int firstDelete = backend.Events.FindIndex(e => e.StartsWith("delete:"));
Assert.Equal(2, backend.Events.Take(firstDelete).Count(e => e.StartsWith("nonresident:")));
Assert.Equal(2, backend.Deleted.Count);
cache.Dispose();
Assert.Equal(4, backend.Events.Count);
}
[Fact]
public void StructuralPaletteEqualityRejectsSameHashWithDifferentRanges()
{
const ulong collision = 1234;
var left = new PaletteCompositeIdentity(
new PaletteOverride(1, [new PaletteOverride.SubPaletteRange(2, 3, 4)]),
collision);
var right = new PaletteCompositeIdentity(
new PaletteOverride(1, [new PaletteOverride.SubPaletteRange(9, 3, 4)]),
collision);
Assert.NotEqual(left, right);
Assert.NotEqual(
new CompositeTextureKey(CompositeTextureKind.PaletteComposite, 1, 0, left),
new CompositeTextureKey(CompositeTextureKind.PaletteComposite, 1, 0, right));
}
[Fact]
public void UploadBudgetIncludesIncomingLayerAndResetsEachFrame()
{
var backend = new FakeBackend(maximumLayers: 8);
using var cache = new CompositeTextureArrayCache(
backend,
new DeferredRetirementQueue(),
unownedBudgetBytes: long.MaxValue,
physicalBudgetBytes: long.MaxValue,
maximumUploadsPerFrame: 8,
maximumUploadBytesPerFrame: 100);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); // 64 bytes
Assert.False(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _));
Assert.False(cache.CanStartUpload);
Assert.Single(backend.Uploads);
cache.BeginFrame();
Assert.True(cache.CanStartUpload);
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(4, 4), out _));
Assert.Equal(2, backend.Uploads.Count);
}
[Fact]
public void OversizedTextureIsAllowedAsOnlyUploadToGuaranteeProgress()
{
var backend = new FakeBackend(maximumLayers: 8);
using var cache = new CompositeTextureArrayCache(
backend,
new DeferredRetirementQueue(),
unownedBudgetBytes: long.MaxValue,
physicalBudgetBytes: long.MaxValue,
maximumUploadsPerFrame: 8,
maximumUploadBytesPerFrame: 32);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
Assert.False(cache.TryAddAndAcquire(1, Key(2), Texture(1, 1), out _));
Assert.Single(backend.Uploads);
}
[Fact]
public void DimensionPreflightRejectsBeforeAllocatingDecodedPixelsForBlockedUpload()
{
var backend = new FakeBackend(maximumLayers: 8);
using var cache = new CompositeTextureArrayCache(
backend,
new DeferredRetirementQueue(),
unownedBudgetBytes: long.MaxValue,
physicalBudgetBytes: long.MaxValue,
maximumUploadsPerFrame: 8,
maximumUploadBytesPerFrame: 100);
cache.BeginFrame();
Assert.True(cache.CanPrepareUpload(4, 4));
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _)); // 64 bytes
Assert.False(cache.CanPrepareUpload(4, 4));
Assert.False(cache.CanStartUpload);
Assert.Single(backend.Uploads);
}
[Fact]
public void DimensionPreflightHonorsOneNewArrayPerFrame()
{
var backend = new FakeBackend(maximumLayers: 8);
using var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
Assert.False(cache.CanPrepareUpload(8, 8));
Assert.False(cache.CanStartUpload);
Assert.Single(backend.Created);
cache.BeginFrame();
cache.Tick();
Assert.True(cache.CanPrepareUpload(8, 8));
}
[Fact]
public void PhysicalAdmissionReclaimsCompatibleLayerBeforeAllocatingAgain()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: long.MaxValue,
physicalBudgetBytes: 64);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out BindlessTextureLocation first));
cache.ReleaseOwner(1);
cache.BeginFrame();
Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _));
Assert.Single(backend.Created);
Assert.Equal(64, cache.AllocatedBytes);
cache.Tick();
Assert.Single(retirements.Actions);
retirements.DrainAll();
cache.BeginFrame();
cache.Tick();
Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out BindlessTextureLocation reused));
Assert.Equal(first.Handle, reused.Handle);
Assert.Single(backend.Created);
Assert.Equal(64, cache.AllocatedBytes);
}
[Fact]
public void AtlasRelease_NonResidentFailureRetainsAtlasAndAccountingForRetry()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.ReleaseOwner(1);
cache.Tick();
retirements.DrainAll();
backend.FailNextNonResident = true;
Assert.Throws<InvalidOperationException>(cache.Tick);
Assert.Equal(64, cache.AllocatedBytes);
Assert.Equal(1, cache.AtlasCount);
Assert.Empty(backend.Deleted);
cache.Tick();
Assert.Equal(0, cache.AllocatedBytes);
Assert.Equal(0, cache.AtlasCount);
Assert.Single(backend.NonResident);
Assert.Single(backend.Deleted);
}
[Fact]
public void AtlasRelease_DeleteFailureRevokesReuseButPreservesPhysicalAccounting()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.ReleaseOwner(1);
cache.Tick();
retirements.DrainAll();
backend.FailNextDelete = true;
Assert.Throws<InvalidOperationException>(cache.Tick);
Assert.Equal(64, cache.AllocatedBytes);
Assert.Single(backend.NonResident);
Assert.Empty(backend.Deleted);
cache.BeginFrame();
Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _));
Assert.Single(backend.Created);
cache.Tick();
Assert.Equal(0, cache.AllocatedBytes);
Assert.Single(backend.NonResident);
Assert.Single(backend.Deleted);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _));
Assert.Equal(2, backend.Created.Count);
}
[Fact]
public void AtlasRelease_PostCommitNonResidentFailureDoesNotReplayMutation()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.ReleaseOwner(1);
cache.Tick();
retirements.DrainAll();
backend.FailNextNonResidentAfterCommit = true;
Assert.Throws<GpuResourceMutationException>(cache.Tick);
Assert.Single(backend.NonResident);
Assert.Empty(backend.Deleted);
cache.Tick();
Assert.Single(backend.NonResident);
Assert.Single(backend.Deleted);
Assert.Equal(0, cache.AllocatedBytes);
}
[Fact]
public void AtlasRelease_PostCommitDeleteFailureDoesNotDeleteOrAccountTwice()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 0);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.ReleaseOwner(1);
cache.Tick();
retirements.DrainAll();
backend.FailNextDeleteAfterCommit = true;
Assert.Throws<GpuResourceMutationException>(cache.Tick);
Assert.Single(backend.NonResident);
Assert.Single(backend.Deleted);
Assert.Equal(64, cache.AllocatedBytes);
cache.Tick();
Assert.Single(backend.Deleted);
Assert.Equal(0, cache.AllocatedBytes);
}
[Fact]
public void LayerRetirement_PublicationFailureKeepsSlotUnavailableUntilRetry()
{
var backend = new FakeBackend(maximumLayers: 1);
var retirements = new DeferredRetirementQueue { FailNextRetire = true };
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 0,
physicalBudgetBytes: 64);
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.ReleaseOwner(1);
Assert.Throws<InvalidOperationException>(cache.Tick);
Assert.Equal(0, cache.CachedEntryCount);
Assert.Empty(retirements.Actions);
cache.BeginFrame();
Assert.Single(retirements.Actions);
cache.BeginFrame();
Assert.False(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _));
retirements.DrainAll();
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(2, Key(2), Texture(4, 4), out _));
Assert.Single(backend.Created);
}
[Fact]
public void Dispose_ResidencyFailureDeletesNothingAndRetryResumesExactAtlas()
{
var backend = new FakeBackend(maximumLayers: 1);
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(1), Texture(4, 4), out _));
cache.BeginFrame();
Assert.True(cache.TryAddAndAcquire(1, Key(2), Texture(8, 8), out _));
backend.FailNextNonResident = true;
Assert.Throws<AggregateException>(cache.Dispose);
Assert.Empty(backend.Deleted);
Assert.Single(backend.NonResident);
cache.Dispose();
Assert.Equal(2, backend.NonResident.Count);
Assert.Equal(2, backend.Deleted.Count);
Assert.Equal(0, cache.AllocatedBytes);
}
private static CompositeTextureArrayCache CreateCache(
FakeBackend backend,
DeferredRetirementQueue retirements,
long unownedBudgetBytes = long.MaxValue,
long physicalBudgetBytes = long.MaxValue) =>
new(
backend,
retirements,
unownedBudgetBytes,
physicalBudgetBytes,
maximumUploadsPerFrame: 100,
maximumUploadBytesPerFrame: long.MaxValue);
private static CompositeTextureKey Key(uint id) =>
new(CompositeTextureKind.OriginalTextureOverride, id, id + 100, default);
private static DecodedTexture Texture(int width, int height) =>
new(new byte[width * height * 4], width, height);
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
{
private readonly Queue<Action> _actions = new();
public int Count => _actions.Count;
public IReadOnlyCollection<Action> Actions => _actions;
public bool FailNextRetire { get; set; }
public void Retire(Action release)
{
if (FailNextRetire)
{
FailNextRetire = false;
throw new InvalidOperationException("synthetic retirement publication failure");
}
_actions.Enqueue(release);
}
public void DrainAll()
{
while (_actions.TryDequeue(out Action? action))
action();
}
}
private sealed class FakeBackend(int maximumLayers) : ICompositeTextureArrayBackend
{
private uint _nextName = 1;
public int MaximumArrayLayers { get; } = maximumLayers;
public List<CompositeTextureArrayResource> Created { get; } = [];
public List<(uint Name, int Layer)> Uploads { get; } = [];
public List<uint> NonResident { get; } = [];
public List<uint> Deleted { get; } = [];
public List<string> Events { get; } = [];
public bool FailNextUpload { get; set; }
public bool FailNextNonResident { get; set; }
public bool FailNextDelete { get; set; }
public bool FailNextNonResidentAfterCommit { get; set; }
public bool FailNextDeleteAfterCommit { get; set; }
public CompositeTextureArrayResource Create(int width, int height, int capacity)
{
uint name = _nextName++;
var resource = new CompositeTextureArrayResource
{
Name = name,
Handle = 1000UL + name,
Width = width,
Height = height,
Capacity = capacity,
Bytes = (long)width * height * 4 * capacity,
};
Created.Add(resource);
return resource;
}
public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba)
{
if (FailNextUpload)
{
FailNextUpload = false;
throw new InvalidOperationException("synthetic upload failure");
}
Uploads.Add((resource.Name, layer));
}
public void MakeNonResident(CompositeTextureArrayResource resource)
{
if (FailNextNonResident)
{
FailNextNonResident = false;
throw new InvalidOperationException("synthetic non-resident failure");
}
NonResident.Add(resource.Name);
Events.Add($"nonresident:{resource.Name}");
if (FailNextNonResidentAfterCommit)
{
FailNextNonResidentAfterCommit = false;
throw new GpuResourceMutationException(
"synthetic post-commit nonresident failure",
mutationCommitted: true,
new InvalidOperationException("observer"));
}
}
public void Delete(CompositeTextureArrayResource resource)
{
if (FailNextDelete)
{
FailNextDelete = false;
throw new InvalidOperationException("synthetic delete failure");
}
Deleted.Add(resource.Name);
Events.Add($"delete:{resource.Name}");
if (FailNextDeleteAfterCommit)
{
FailNextDeleteAfterCommit = false;
throw new GpuResourceMutationException(
"synthetic post-commit delete failure",
mutationCommitted: true,
new InvalidOperationException("observer"));
}
}
}
}

View file

@ -0,0 +1,23 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class DynamicBufferCapacityTests
{
[Theory]
[InlineData(0, 1, 4096)]
[InlineData(4096, 4096, 4096)]
[InlineData(4096, 4097, 8192)]
[InlineData(8192, 9000, 16384)]
[InlineData(0, 8193, 12288)]
public void Grow_UsesAlignedGeometricCapacity(int current, int required, int expected)
=> Assert.Equal(expected, DynamicBufferCapacity.Grow(current, required));
[Fact]
public void Grow_RejectsInvalidInputs()
{
Assert.Throws<ArgumentOutOfRangeException>(() => DynamicBufferCapacity.Grow(-1, 1));
Assert.Throws<ArgumentOutOfRangeException>(() => DynamicBufferCapacity.Grow(0, -1));
Assert.Throws<ArgumentOutOfRangeException>(() => DynamicBufferCapacity.Grow(0, 1, 0));
}
}

View file

@ -0,0 +1,46 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering;
public sealed class FixedEntityTextureOwnerLeaseTests
{
[Fact]
public void RepeatedReplacementReleasesHistoricalKeysForTheFixedOwner()
{
const uint owner = 42;
var lifetime = new RegistryLifetime();
using var lease = new FixedEntityTextureOwnerLease(lifetime, owner);
lease.Replace(hasReplacement: true);
lifetime.Acquire(owner, "first");
Assert.Equal(1, lifetime.ResourceCount);
lease.Replace(hasReplacement: true);
lifetime.Acquire(owner, "second");
Assert.Equal(1, lifetime.ResourceCount);
lease.Replace(hasReplacement: true);
lifetime.Acquire(owner, "third");
Assert.Equal(1, lifetime.ResourceCount);
lease.Replace(hasReplacement: false);
Assert.Equal(0, lifetime.ResourceCount);
Assert.Equal(3, lifetime.ReleaseCount);
}
private sealed class RegistryLifetime : IEntityTextureLifetime
{
private readonly OwnerScopedResourceRegistry<string> _registry = new();
public int ResourceCount => _registry.ResourceCount;
public int ReleaseCount { get; private set; }
public void Acquire(uint owner, string key) => _registry.Acquire(owner, key);
public void ReleaseOwner(uint localEntityId)
{
ReleaseCount++;
_registry.ReleaseOwner(localEntityId);
}
}
}

View file

@ -0,0 +1,116 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class FramePacingControllerTests
{
[Fact]
public void Software_pacing_waits_only_for_remaining_deadline_time()
{
var clock = new FakeClock(frequency: 1_000);
var waiter = new AdvancingWaiter(clock);
var controller = new FramePacingController(clock, waiter);
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d));
clock.Timestamp = 40;
controller.CompleteFrame();
Assert.Equal([60L], waiter.Durations);
clock.Timestamp = 150;
controller.CompleteFrame();
Assert.Equal([60L, 50L], waiter.Durations);
}
[Fact]
public void Missed_deadline_rebases_instead_of_running_a_catch_up_frame()
{
var clock = new FakeClock(frequency: 1_000);
var waiter = new AdvancingWaiter(clock);
var controller = new FramePacingController(clock, waiter);
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d));
clock.Timestamp = 250;
controller.CompleteFrame();
Assert.Empty(waiter.Durations);
// Rebased deadline is 350, not one of the elapsed 100/200/300
// deadlines. The next frame therefore waits instead of bursting.
clock.Timestamp = 300;
controller.CompleteFrame();
Assert.Equal([50L], waiter.Durations);
}
[Fact]
public void Wait_overshoot_rebases_future_deadline()
{
var clock = new FakeClock(frequency: 1_000);
var waiter = new AdvancingWaiter(clock) { OvershootTicks = 150 };
var controller = new FramePacingController(clock, waiter);
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d));
controller.CompleteFrame();
Assert.Equal(250, clock.Timestamp);
waiter.OvershootTicks = 0;
clock.Timestamp = 300;
controller.CompleteFrame();
Assert.Equal([100L, 50L], waiter.Durations);
}
[Fact]
public void VSync_and_explicit_uncapped_policies_do_not_software_wait()
{
var clock = new FakeClock(frequency: 1_000);
var waiter = new AdvancingWaiter(clock);
var controller = new FramePacingController(clock, waiter);
controller.Apply(new FramePacingPolicy(UseVSync: true, SoftwareLimitHz: null));
clock.Timestamp = 1_000;
controller.CompleteFrame();
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: null));
clock.Timestamp = 2_000;
controller.CompleteFrame();
Assert.Empty(waiter.Durations);
}
[Fact]
public void Rate_change_resets_deadline_from_current_monotonic_time()
{
var clock = new FakeClock(frequency: 1_000);
var waiter = new AdvancingWaiter(clock);
var controller = new FramePacingController(clock, waiter);
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 10d));
clock.Timestamp = 25;
controller.Apply(new FramePacingPolicy(UseVSync: false, SoftwareLimitHz: 20d));
clock.Timestamp = 50;
controller.CompleteFrame();
Assert.Equal([25L], waiter.Durations);
}
private sealed class FakeClock(long frequency) : IFramePacingClock
{
public long Frequency { get; } = frequency;
public long Timestamp { get; set; }
public long GetTimestamp() => Timestamp;
}
private sealed class AdvancingWaiter(FakeClock clock) : IFramePacingWaiter
{
public List<long> Durations { get; } = [];
public long OvershootTicks { get; set; }
public void Wait(long durationTicks, long clockFrequency)
{
Assert.Equal(clock.Frequency, clockFrequency);
Durations.Add(durationTicks);
clock.Timestamp += durationTicks + OvershootTicks;
}
}
}

View file

@ -0,0 +1,63 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class FramePacingPolicyTests
{
[Fact]
public void VSync_request_uses_driver_swap_without_software_limit()
{
var policy = FramePacingPolicy.Resolve(
requestedVSync: true,
uncappedRendering: false,
monitorRefreshHz: 144);
Assert.True(policy.UseVSync);
Assert.Null(policy.SoftwareLimitHz);
}
[Theory]
[InlineData(60)]
[InlineData(120)]
[InlineData(144)]
public void Normal_VSync_off_uses_active_monitor_refresh_limit(int refreshHz)
{
var policy = FramePacingPolicy.Resolve(
requestedVSync: false,
uncappedRendering: false,
monitorRefreshHz: refreshHz);
Assert.False(policy.UseVSync);
Assert.Equal((double)refreshHz, policy.SoftwareLimitHz);
}
[Theory]
[InlineData(null)]
[InlineData(0)]
[InlineData(-1)]
public void Missing_or_invalid_monitor_refresh_uses_safe_fallback(int? refreshHz)
{
var policy = FramePacingPolicy.Resolve(
requestedVSync: false,
uncappedRendering: false,
monitorRefreshHz: refreshHz);
Assert.False(policy.UseVSync);
Assert.Equal(FramePacingPolicy.FallbackRefreshHz, policy.SoftwareLimitHz);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Explicit_uncapped_diagnostic_disables_both_pacing_mechanisms(
bool requestedVSync)
{
var policy = FramePacingPolicy.Resolve(
requestedVSync,
uncappedRendering: true,
monitorRefreshHz: 144);
Assert.False(policy.UseVSync);
Assert.Null(policy.SoftwareLimitHz);
}
}

View file

@ -0,0 +1,212 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GpuFrameFlightControllerTests
{
[Fact]
public void FourthFrameRetiresOldestFenceBeforeReusingItsSlot()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 3);
SubmitFrame(controller);
SubmitFrame(controller);
SubmitFrame(controller);
Assert.Empty(api.Waited);
controller.BeginFrame();
Assert.Equal([1], api.Waited);
Assert.Equal([1], api.Deleted);
controller.EndFrame();
Assert.Equal([1, 2, 3, 4], api.Inserted);
}
[Fact]
public void TimeoutIsRetriedAndOnlyFirstWaitFlushesCommands()
{
var api = new FakeFenceApi();
api.Results.Enqueue(GpuFenceWaitResult.Timeout);
api.Results.Enqueue(GpuFenceWaitResult.Signaled);
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1);
SubmitFrame(controller);
controller.BeginFrame();
Assert.Equal([true, false], api.FlushRequests);
Assert.Equal([1, 1], api.Waited);
Assert.Equal([1], api.Deleted);
}
[Fact]
public void WaitFailureDoesNotDeleteOrReuseUnretiredFence()
{
var api = new FakeFenceApi();
api.Results.Enqueue(GpuFenceWaitResult.Failed);
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1);
SubmitFrame(controller);
Assert.Throws<InvalidOperationException>(() => controller.BeginFrame());
Assert.Empty(api.Deleted);
Assert.Throws<InvalidOperationException>(() => controller.EndFrame());
}
[Fact]
public void DisposeDeletesEveryOutstandingFenceExactlyOnce()
{
var api = new FakeFenceApi();
var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 3);
SubmitFrame(controller);
SubmitFrame(controller);
SubmitFrame(controller);
controller.Dispose();
controller.Dispose();
Assert.Equal([1, 2, 3], api.Deleted);
}
[Fact]
public void RetirementAfterSubmittedFrameWaitsForItsFence()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 2);
int releases = 0;
SubmitFrame(controller);
controller.Retire(() => releases++);
Assert.Equal(0, releases);
SubmitFrame(controller);
Assert.Equal(0, releases);
controller.BeginFrame();
Assert.Equal(1, releases);
controller.EndFrame();
}
[Fact]
public void RetirementDuringFrameIncludesCurrentFrameFence()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1);
int releases = 0;
controller.BeginFrame();
controller.Retire(() => releases++);
Assert.Equal(0, releases);
controller.EndFrame();
controller.BeginFrame();
Assert.Equal(1, releases);
controller.EndFrame();
}
[Fact]
public void RetirementBeforeFirstFrameRunsImmediately()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api);
int releases = 0;
controller.Retire(() => releases++);
Assert.Equal(1, releases);
Assert.Equal(0, controller.PendingRetirementCount);
}
[Fact]
public void ThrowingRetirementDoesNotDropLaterCallbacksInCompletedBucket()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 1);
int releases = 0;
int attempts = 0;
SubmitFrame(controller);
controller.Retire(() =>
{
if (++attempts == 1)
throw new InvalidOperationException("first");
});
controller.Retire(() => releases++);
AggregateException error = Assert.Throws<AggregateException>(() => controller.BeginFrame());
Assert.Single(error.InnerExceptions);
Assert.Equal(1, releases);
Assert.Equal(1, controller.PendingRetirementCount);
controller.WaitForSubmittedWork();
Assert.Equal(2, attempts);
Assert.Equal(0, controller.PendingRetirementCount);
}
[Fact]
public void WaitForSubmittedWorkDrainsLaterFencesAfterEarlierRetirementThrows()
{
var api = new FakeFenceApi();
using var controller = new GpuFrameFlightController(api, maximumFramesInFlight: 2);
int laterReleases = 0;
bool failOnce = true;
SubmitFrame(controller);
controller.Retire(() =>
{
if (failOnce)
{
failOnce = false;
throw new InvalidOperationException("first frame");
}
});
SubmitFrame(controller);
controller.Retire(() => laterReleases++);
AggregateException error = Assert.Throws<AggregateException>(controller.WaitForSubmittedWork);
Assert.Single(error.InnerExceptions);
Assert.Equal(1, laterReleases);
Assert.Equal([1, 2], api.Deleted);
Assert.Equal(0, controller.PendingRetirementCount);
}
private static void SubmitFrame(GpuFrameFlightController controller)
{
controller.BeginFrame();
controller.EndFrame();
}
private sealed class FakeFenceApi : IGpuFenceApi
{
private nint _nextFence = 1;
public List<nint> Inserted { get; } = [];
public List<nint> Waited { get; } = [];
public List<bool> FlushRequests { get; } = [];
public List<nint> Deleted { get; } = [];
public Queue<GpuFenceWaitResult> Results { get; } = new();
public nint Insert()
{
nint fence = _nextFence++;
Inserted.Add(fence);
return fence;
}
public GpuFenceWaitResult Wait(nint fence, bool flushCommands, ulong timeoutNanoseconds)
{
Assert.Equal(1_000_000ul, timeoutNanoseconds);
Waited.Add(fence);
FlushRequests.Add(flushCommands);
return Results.TryDequeue(out GpuFenceWaitResult result)
? result
: GpuFenceWaitResult.Signaled;
}
public void Delete(nint fence) => Deleted.Add(fence);
}
}

View file

@ -0,0 +1,372 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Tests.Rendering;
public sealed class GpuResourceRetirementTransactionTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void Release_RetryResumesAtFirstUncommittedStage(int failingStage)
{
int[] calls = new int[4];
bool failed = false;
Action[] stages = Enumerable.Range(0, calls.Length)
.Select<int, Action>(stage => () =>
{
calls[stage]++;
if (stage == failingStage && !failed)
{
failed = true;
throw new InvalidOperationException($"stage {stage}");
}
})
.ToArray();
var release = new RetryableGpuResourceRelease(stages);
Assert.Throws<InvalidOperationException>(release.Run);
Assert.Equal(failingStage, release.CompletedStageCount);
release.Run();
Assert.True(release.IsComplete);
for (int stage = 0; stage < calls.Length; stage++)
Assert.Equal(stage == failingStage ? 2 : 1, calls[stage]);
}
[Fact]
public void Ledger_QueueInsertionFailureRetainsReleaseForPublicationRetry()
{
var queue = new FailBeforeAcceptQueue();
var ledger = new GpuRetirementLedger(queue);
int releases = 0;
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => releases++)));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Equal(0, releases);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
queue.Actions.Single()();
Assert.Equal(1, releases);
}
[Fact]
public void Ledger_ImmediateCallbackFailureRetainsCommittedStageCursor()
{
var ledger = new GpuRetirementLedger(ImmediateGpuResourceRetirementQueue.Instance);
int first = 0;
int second = 0;
bool failSecond = true;
var release = new RetryableGpuResourceRelease(
() => first++,
() =>
{
second++;
if (failSecond)
{
failSecond = false;
throw new InvalidOperationException("second stage");
}
});
Assert.Throws<InvalidOperationException>(() => ledger.Retire(release));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Equal(1, release.CompletedStageCount);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(1, first);
Assert.Equal(2, second);
}
[Fact]
public void Release_ReentrantDrainDoesNotReplayActiveStage()
{
RetryableGpuResourceRelease? release = null;
int active = 0;
int tail = 0;
release = new RetryableGpuResourceRelease(
() =>
{
active++;
release!.Run();
},
() => tail++);
release.Run();
Assert.True(release.IsComplete);
Assert.Equal(1, active);
Assert.Equal(1, tail);
}
[Fact]
public void Release_PostMutationValidationFailureDoesNotReplayMutationStage()
{
int mutations = 0;
int validations = 0;
int accounting = 0;
var release = new RetryableGpuResourceRelease(
() => mutations++,
() =>
{
validations++;
if (validations == 1)
throw new InvalidOperationException("post-mutation validation");
},
() => accounting++);
Assert.Throws<InvalidOperationException>(release.Run);
release.Run();
Assert.Equal(1, mutations);
Assert.Equal(2, validations);
Assert.Equal(1, accounting);
Assert.True(release.IsComplete);
}
[Fact]
public void Ledger_RetryAttemptsEveryPendingPublicationDespiteOneFailure()
{
var queue = new FailFirstNQueue(3);
var ledger = new GpuRetirementLedger(queue);
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => { })));
Assert.Throws<InvalidOperationException>(() =>
ledger.Retire(new RetryableGpuResourceRelease(() => { })));
AggregateException error = Assert.Throws<AggregateException>(
ledger.RetryPendingPublications);
Assert.Single(error.InnerExceptions);
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublications();
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void Ledger_RetrySpecificPublicationDoesNotRepublishOtherRelease()
{
var queue = new FailFirstNQueue(2);
var ledger = new GpuRetirementLedger(queue);
var first = new RetryableGpuResourceRelease(() => { });
var second = new RetryableGpuResourceRelease(() => { });
Assert.Throws<InvalidOperationException>(() => ledger.Retire(first));
Assert.Throws<InvalidOperationException>(() => ledger.Retire(second));
ledger.RetryPendingPublication(first);
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublication(second);
Assert.Equal(0, ledger.AwaitingPublicationCount);
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void Ledger_BatchOwnsEveryReleaseBeforePublishingFirst()
{
var queue = new FailFirstNQueue(1);
var ledger = new GpuRetirementLedger(queue);
var first = new RetryableGpuResourceRelease(() => { });
var second = new RetryableGpuResourceRelease(() => { });
Assert.Throws<AggregateException>(() => ledger.RetireMany([first, second]));
Assert.Equal(1, ledger.AwaitingPublicationCount);
Assert.Single(queue.Actions);
ledger.RetryPendingPublications();
Assert.Equal(2, queue.Actions.Count);
}
[Fact]
public void GlQueue_PersistentNextPassRetryDoesNotStarveOrdinaryWork()
{
var device = new QueueOnlyGraphicsDevice();
int retryCalls = 0;
int ordinaryCalls = 0;
Action<GL>? retry = null;
retry = _ =>
{
retryCalls++;
device.QueueGLActionForNextPass(retry!);
};
device.QueueGLActionForNextPass(retry);
device.QueueGLAction(_ => ordinaryCalls++);
device.ProcessGLQueue();
Assert.Equal(1, retryCalls);
Assert.Equal(1, ordinaryCalls);
Assert.True(device.HasPendingGLWork);
}
[Fact]
public void GlQueue_ReportsPendingWorkUntilBothGenerationsDrain()
{
var device = new QueueOnlyGraphicsDevice();
device.QueueGLAction(_ => { });
Assert.True(device.HasPendingGLWork);
device.ProcessGLQueue();
Assert.False(device.HasPendingGLWork);
}
[Fact]
public void MigrationAbortTicket_RetainsBufferUntilEveryReleaseStageConverges()
{
int deleteCalls = 0;
int accountingCalls = 0;
bool failAccounting = true;
var ticket = new GlobalMeshMigrationAbortTicket(
buffer: 37,
capacityBytes: 4096,
new RetryableGpuResourceRelease(
() => deleteCalls++,
() =>
{
if (failAccounting)
{
failAccounting = false;
throw new InvalidOperationException("injected accounting failure");
}
accountingCalls++;
}));
Assert.Throws<InvalidOperationException>(ticket.Advance);
Assert.False(ticket.IsComplete);
Assert.Equal((uint)37, ticket.Buffer);
Assert.Equal(4096, ticket.CapacityBytes);
Assert.Equal(1, deleteCalls);
Assert.Equal(0, accountingCalls);
ticket.Advance();
ticket.Advance();
Assert.True(ticket.IsComplete);
Assert.Equal(1, deleteCalls);
Assert.Equal(1, accountingCalls);
}
[Fact]
public void MigrationAbortTicket_DeleteValidationFailureRetriesBeforeAccounting()
{
int deleteCalls = 0;
int accountingCalls = 0;
bool failDeleteValidation = true;
var ticket = new GlobalMeshMigrationAbortTicket(
buffer: 41,
capacityBytes: 8192,
new RetryableGpuResourceRelease(
() =>
{
deleteCalls++;
if (failDeleteValidation)
{
failDeleteValidation = false;
throw new InvalidOperationException("injected GL delete validation failure");
}
},
() => accountingCalls++));
Assert.Throws<InvalidOperationException>(ticket.Advance);
Assert.False(ticket.IsComplete);
Assert.Equal(1, deleteCalls);
Assert.Equal(0, accountingCalls);
ticket.Advance();
Assert.True(ticket.IsComplete);
Assert.Equal(2, deleteCalls);
Assert.Equal(1, accountingCalls);
}
[Fact]
public void GlobalMeshVaoAccounting_CreateAndRetryableDeleteBalanceExactlyOnce()
{
int baseline = GpuMemoryTracker.VaoCount;
bool allocationOutstanding = true;
GlobalMeshVaoAccounting.TrackAllocation();
try
{
Assert.Equal(baseline + 1, GpuMemoryTracker.VaoCount);
var release = new RetryableGpuResourceRelease(
() => { },
() =>
{
GlobalMeshVaoAccounting.TrackDeallocation();
allocationOutstanding = false;
});
release.Run();
release.Run();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
finally
{
if (allocationOutstanding)
GlobalMeshVaoAccounting.TrackDeallocation();
}
}
[Fact]
public void GlobalMeshVaoAccounting_InitializationRollbackReturnsToBaseline()
{
int baseline = GpuMemoryTracker.VaoCount;
GlobalMeshVaoAccounting.TrackAllocation();
GlobalMeshVaoAccounting.TrackDeallocation();
Assert.Equal(baseline, GpuMemoryTracker.VaoCount);
}
private sealed class FailBeforeAcceptQueue : IGpuResourceRetirementQueue
{
private bool _fail = true;
public List<Action> Actions { get; } = [];
public void Retire(Action release)
{
if (_fail)
{
_fail = false;
throw new InvalidOperationException("queue insertion");
}
Actions.Add(release);
}
}
private sealed class FailFirstNQueue(int failures) : IGpuResourceRetirementQueue
{
private int _remaining = failures;
public List<Action> Actions { get; } = [];
public void Retire(Action release)
{
if (_remaining-- > 0)
throw new InvalidOperationException("synthetic queue publication failure");
Actions.Add(release);
}
}
private sealed class QueueOnlyGraphicsDevice : OpenGLGraphicsDevice
{
public QueueOnlyGraphicsDevice()
: base()
{
}
}
}

View file

@ -0,0 +1,158 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class GpuRetiredTerrainSlotAllocatorTests
{
[Fact]
public void RemovedTerrainSlotIsNotReusedBeforeGpuRetirement()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int first = allocator.Allocate(out bool firstNeedsGrow);
Assert.False(firstNeedsGrow);
allocator.FreeAfterGpuUse(first);
int whilePending = allocator.Allocate(out bool pendingNeedsGrow);
Assert.NotEqual(first, whilePending);
Assert.True(pendingNeedsGrow);
retirement.RunAll();
int reused = allocator.Allocate(out _);
Assert.Equal(first, reused);
}
[Fact]
public void UnsubmittedTerrainSlotIsReusableImmediately()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int first = allocator.Allocate(out bool firstNeedsGrow);
Assert.False(firstNeedsGrow);
allocator.ReleaseUnsubmitted(first);
int reused = allocator.Allocate(out bool reusedNeedsGrow);
Assert.Equal(first, reused);
Assert.False(reusedNeedsGrow);
}
[Fact]
public void PublicationFailure_RetainsSlotUntilRetryIsAccepted()
{
var retirement = new DeferredRetirementQueue { FailNextPublication = true };
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int slot = allocator.Allocate(out _);
Assert.Throws<InvalidOperationException>(() => allocator.FreeAfterGpuUse(slot));
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(0, retirement.Count);
int whileUnpublished = allocator.Allocate(out bool needsGrow);
Assert.NotEqual(slot, whileUnpublished);
Assert.True(needsGrow);
allocator.FreeAfterGpuUse(slot);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(1, retirement.Count);
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
int reused = allocator.Allocate(out _);
Assert.Equal(slot, reused);
}
[Fact]
public void DuplicateFreeBeforeRetirement_IsIdempotent()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int slot = allocator.Allocate(out _);
allocator.FreeAfterGpuUse(slot);
allocator.FreeAfterGpuUse(slot);
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(1, retirement.Count);
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
Assert.Equal(slot, allocator.Allocate(out _));
}
[Fact]
public void RetryPendingPublications_AdvancesLogicallyRemovedSlot()
{
var retirement = new DeferredRetirementQueue { FailNextPublication = true };
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int slot = allocator.Allocate(out _);
Assert.Throws<InvalidOperationException>(() => allocator.FreeAfterGpuUse(slot));
allocator.RetryPendingPublications();
Assert.Equal(1, allocator.PendingReleaseCount);
Assert.Equal(1, retirement.Count);
retirement.RunAll();
Assert.Equal(0, allocator.PendingReleaseCount);
Assert.Equal(slot, allocator.Allocate(out _));
}
[Fact]
public void ReplacementPublicationFailure_RetiresOnlyReplacedSlotAfterRetry()
{
var retirement = new DeferredRetirementQueue { FailNextPublication = true };
var allocator = new GpuRetiredTerrainSlotAllocator(2, retirement);
int replaced = allocator.Allocate(out _);
int replacement = allocator.Allocate(out _);
Assert.Throws<InvalidOperationException>(() => allocator.FreeAfterGpuUse(replaced));
Assert.Equal(2, allocator.LoadedCount);
Assert.Equal(1, allocator.PendingReleaseCount);
allocator.RetryPendingPublications();
retirement.RunAll();
Assert.Equal(1, allocator.LoadedCount);
Assert.Equal(0, allocator.PendingReleaseCount);
int reused = allocator.Allocate(out _);
Assert.Equal(replaced, reused);
Assert.NotEqual(replacement, reused);
}
[Fact]
public void PendingSubmittedSlot_CannotBeReleasedAsUnsubmitted()
{
var retirement = new DeferredRetirementQueue();
var allocator = new GpuRetiredTerrainSlotAllocator(1, retirement);
int slot = allocator.Allocate(out _);
allocator.FreeAfterGpuUse(slot);
Assert.Throws<InvalidOperationException>(() => allocator.ReleaseUnsubmitted(slot));
Assert.Equal(1, allocator.PendingReleaseCount);
}
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
{
private readonly List<Action> _releases = [];
public int Count => _releases.Count;
public bool FailNextPublication { get; set; }
public void Retire(Action release)
{
if (FailNextPublication)
{
FailNextPublication = false;
throw new InvalidOperationException("Injected publication failure.");
}
_releases.Add(release);
}
public void RunAll()
{
foreach (Action release in _releases)
release();
_releases.Clear();
}
}
}

View file

@ -0,0 +1,52 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class OrderedResourceTeardownTests
{
[Fact]
public void FailureStopsDependentsAndRetryResumesExactStage()
{
var calls = new List<int>();
int attempts = 0;
var teardown = new OrderedResourceTeardown(
() => calls.Add(1),
() =>
{
calls.Add(2);
if (attempts++ == 0)
throw new InvalidOperationException("synthetic stage failure");
},
() => calls.Add(3));
Assert.Throws<InvalidOperationException>(teardown.Advance);
Assert.Equal([1, 2], calls);
Assert.Equal(1, teardown.NextStage);
teardown.Advance();
Assert.Equal([1, 2, 2, 3], calls);
Assert.True(teardown.IsComplete);
}
[Fact]
public void ReentrantAdvanceDoesNotReplayActiveStage()
{
OrderedResourceTeardown? teardown = null;
int first = 0;
int second = 0;
teardown = new OrderedResourceTeardown(
() =>
{
first++;
teardown!.Advance();
},
() => second++);
teardown.Advance();
Assert.Equal(1, first);
Assert.Equal(1, second);
Assert.True(teardown.IsComplete);
}
}

View file

@ -0,0 +1,44 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class OwnerScopedResourceRegistryTests
{
[Fact]
public void RepeatedAcquireBySameOwnerIsIdempotent()
{
var registry = new OwnerScopedResourceRegistry<string>();
Assert.True(registry.Acquire(1, "shared"));
Assert.False(registry.Acquire(1, "shared"));
Assert.Equal(1, registry.OwnerCount);
Assert.Equal(1, registry.ResourceCount);
Assert.Equal(["shared"], registry.ReleaseOwner(1));
}
[Fact]
public void SharedResourceRetiresOnlyAfterFinalOwner()
{
var registry = new OwnerScopedResourceRegistry<string>();
registry.Acquire(1, "shared");
registry.Acquire(2, "shared");
Assert.Empty(registry.ReleaseOwner(1));
Assert.Equal(1, registry.ResourceCount);
Assert.Equal(["shared"], registry.ReleaseOwner(2));
Assert.Equal(0, registry.ResourceCount);
}
[Fact]
public void ReleaseOwnerReturnsAllNewlyUnownedResourcesOnce()
{
var registry = new OwnerScopedResourceRegistry<int>();
registry.Acquire(7, 10);
registry.Acquire(7, 20);
Assert.Equal([10, 20], registry.ReleaseOwner(7).Order());
Assert.Empty(registry.ReleaseOwner(7));
Assert.Equal(0, registry.OwnerCount);
Assert.Equal(0, registry.ResourceCount);
}
}

View file

@ -0,0 +1,115 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering;
public sealed class ParticleEmitterRetirementTrackerTests
{
[Fact]
public void MeshFailureDoesNotBlockGfxOrTextureCleanupAndIsRetried()
{
int meshAttempts = 0;
var removedGfx = new List<int>();
var releasedTextures = new List<int>();
var failures = new List<Exception>();
var tracker = new ParticleEmitterRetirementTracker(
_ =>
{
meshAttempts++;
if (meshAttempts == 1)
throw new InvalidOperationException("mesh release failed");
},
removedGfx.Add,
releasedTextures.Add,
failures.Add);
tracker.BeginRetirement(42);
Assert.Equal([42], removedGfx);
Assert.Equal([42], releasedTextures);
Assert.Single(failures);
Assert.Equal(1, tracker.PendingCount);
tracker.RetryPending();
Assert.Equal(2, meshAttempts);
Assert.Equal([42], removedGfx);
Assert.Equal([42], releasedTextures);
Assert.Equal(0, tracker.PendingCount);
}
[Fact]
public void CommittedMeshFailureIsNotReplayed()
{
int meshAttempts = 0;
var tracker = new ParticleEmitterRetirementTracker(
_ =>
{
meshAttempts++;
throw new MeshReferenceMutationException(
"committed",
mutationCommitted: true,
new InvalidOperationException());
},
_ => { },
_ => { });
tracker.BeginRetirement(42);
tracker.RetryPending();
Assert.Equal(1, meshAttempts);
Assert.Equal(0, tracker.PendingCount);
}
[Fact]
public void IndependentFailuresRetainOnlyTheirOwnObligations()
{
int gfxAttempts = 0;
int textureAttempts = 0;
var tracker = new ParticleEmitterRetirementTracker(
_ => { },
_ =>
{
gfxAttempts++;
if (gfxAttempts == 1)
throw new InvalidOperationException("gfx");
},
_ =>
{
textureAttempts++;
if (textureAttempts == 1)
throw new InvalidOperationException("texture");
});
tracker.BeginRetirement(7);
tracker.RetryPending();
Assert.Equal(2, gfxAttempts);
Assert.Equal(2, textureAttempts);
Assert.Equal(0, tracker.PendingCount);
}
[Fact]
public void SameHandleReentryDoesNotRepeatActiveRetirementStage()
{
ParticleEmitterRetirementTracker? tracker = null;
int meshReleases = 0;
int gfxRemovals = 0;
int textureReleases = 0;
tracker = new ParticleEmitterRetirementTracker(
handle =>
{
meshReleases++;
tracker!.BeginRetirement(handle);
},
_ => gfxRemovals++,
_ => textureReleases++);
tracker.BeginRetirement(42);
Assert.Equal(1, meshReleases);
Assert.Equal(1, gfxRemovals);
Assert.Equal(1, textureReleases);
Assert.Equal(0, tracker.PendingCount);
}
}

View file

@ -0,0 +1,200 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering;
public sealed class PortalMeshReferenceOwnerTests
{
[Fact]
public void PartialAcquireRollsBackEveryCommittedSibling()
{
var adapter = new RecordingMeshAdapter();
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
var owner = new PortalMeshReferenceOwner(
adapter,
[0x01000001u, 0x01000002u]);
Assert.Throws<AggregateException>(owner.Acquire);
Assert.Equal(0, adapter.TotalReferences);
owner.Acquire();
Assert.Equal(2, adapter.IncrementAttempts(0x01000001u));
Assert.Equal(2, adapter.IncrementAttempts(0x01000002u));
owner.Dispose();
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void CommittedAcquireFailureIsRolledBackBeforeRetry()
{
var adapter = new RecordingMeshAdapter();
adapter.FailIncrementAfterCommit(0x01000001u, attempts: 1);
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
Assert.Throws<AggregateException>(owner.Acquire);
Assert.Equal(0, adapter.TotalReferences);
owner.Acquire();
Assert.Equal(2, adapter.IncrementAttempts(0x01000001u));
Assert.Equal(1, adapter.ReferenceCount(0x01000001u));
owner.Dispose();
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void FailedAcquireRollbackRetainsExactReferenceForDisposeRetry()
{
var adapter = new RecordingMeshAdapter();
adapter.FailIncrementBeforeCommit(0x01000001u, attempts: 1);
adapter.FailDecrementBeforeCommit(0x01000002u, attempts: 1);
var owner = new PortalMeshReferenceOwner(
adapter,
[0x01000001u, 0x01000002u]);
AggregateException failure = Assert.Throws<AggregateException>(owner.Acquire);
Assert.Contains("rollback", failure.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, adapter.ReferenceCount(0x01000001u));
Assert.Equal(1, adapter.ReferenceCount(0x01000002u));
owner.Dispose();
Assert.True(owner.IsDisposed);
Assert.Equal(2, adapter.DecrementAttempts(0x01000002u));
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void DisposeAttemptsEveryReferenceAndRetriesOnlyPendingRelease()
{
var adapter = new RecordingMeshAdapter();
var owner = new PortalMeshReferenceOwner(
adapter,
[0x01000001u, 0x01000002u]);
owner.Acquire();
adapter.FailDecrementBeforeCommit(0x01000001u, attempts: 1);
Assert.Throws<AggregateException>(owner.Dispose);
Assert.False(owner.IsDisposed);
Assert.Equal(0, adapter.ReferenceCount(0x01000002u));
owner.Dispose();
owner.Dispose();
Assert.True(owner.IsDisposed);
Assert.Equal(2, adapter.DecrementAttempts(0x01000001u));
Assert.Equal(1, adapter.DecrementAttempts(0x01000002u));
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void CommittedReleaseFailureCompletesOwnershipWithoutDoubleDecrement()
{
var adapter = new RecordingMeshAdapter();
var owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
owner.Acquire();
adapter.FailDecrementAfterCommit(0x01000001u, attempts: 1);
Assert.Throws<AggregateException>(owner.Dispose);
Assert.True(owner.IsDisposed);
owner.Dispose();
Assert.Equal(1, adapter.DecrementAttempts(0x01000001u));
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void DisposeReentryDuringAcquireReconcilesBeforeReturning()
{
PortalMeshReferenceOwner? owner = null;
var adapter = new RecordingMeshAdapter
{
AfterIncrement = _ => owner!.Dispose(),
};
owner = new PortalMeshReferenceOwner(adapter, [0x01000001u]);
Assert.Throws<ObjectDisposedException>(owner.Acquire);
owner.Dispose();
Assert.True(owner.IsDisposed);
Assert.Equal(1, adapter.IncrementAttempts(0x01000001u));
Assert.Equal(1, adapter.DecrementAttempts(0x01000001u));
Assert.Equal(0, adapter.TotalReferences);
}
private sealed class RecordingMeshAdapter : IWbMeshAdapter
{
private readonly Dictionary<ulong, int> _references = [];
private readonly Dictionary<ulong, int> _incrementAttempts = [];
private readonly Dictionary<ulong, int> _decrementAttempts = [];
private readonly Dictionary<ulong, int> _incrementFailuresBeforeCommit = [];
private readonly Dictionary<ulong, int> _incrementFailuresAfterCommit = [];
private readonly Dictionary<ulong, int> _decrementFailuresBeforeCommit = [];
private readonly Dictionary<ulong, int> _decrementFailuresAfterCommit = [];
public Action<ulong>? AfterIncrement { get; init; }
public int TotalReferences => _references.Values.Sum();
public int ReferenceCount(ulong id) => _references.GetValueOrDefault(id);
public int IncrementAttempts(ulong id) => _incrementAttempts.GetValueOrDefault(id);
public int DecrementAttempts(ulong id) => _decrementAttempts.GetValueOrDefault(id);
public void FailIncrementBeforeCommit(ulong id, int attempts) =>
_incrementFailuresBeforeCommit[id] = attempts;
public void FailIncrementAfterCommit(ulong id, int attempts) =>
_incrementFailuresAfterCommit[id] = attempts;
public void FailDecrementBeforeCommit(ulong id, int attempts) =>
_decrementFailuresBeforeCommit[id] = attempts;
public void FailDecrementAfterCommit(ulong id, int attempts) =>
_decrementFailuresAfterCommit[id] = attempts;
public void IncrementRefCount(ulong id)
{
_incrementAttempts[id] = IncrementAttempts(id) + 1;
if (Consume(_incrementFailuresBeforeCommit, id))
throw new InvalidOperationException("synthetic pre-commit acquire failure");
_references[id] = ReferenceCount(id) + 1;
AfterIncrement?.Invoke(id);
if (Consume(_incrementFailuresAfterCommit, id))
{
throw new MeshReferenceMutationException(
"synthetic committed acquire failure",
mutationCommitted: true,
new InvalidOperationException());
}
}
public void DecrementRefCount(ulong id)
{
_decrementAttempts[id] = DecrementAttempts(id) + 1;
if (Consume(_decrementFailuresBeforeCommit, id))
throw new InvalidOperationException("synthetic pre-commit release failure");
int references = ReferenceCount(id);
if (references <= 0)
throw new InvalidOperationException("reference underflow");
_references[id] = references - 1;
if (Consume(_decrementFailuresAfterCommit, id))
{
throw new MeshReferenceMutationException(
"synthetic committed release failure",
mutationCommitted: true,
new InvalidOperationException());
}
}
private static bool Consume(Dictionary<ulong, int> remaining, ulong id)
{
int count = remaining.GetValueOrDefault(id);
if (count <= 0)
return false;
remaining[id] = count - 1;
return true;
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Buffers;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
@ -455,4 +456,237 @@ public class PortalProjectionTests
$"non-finite NDC vert leaked from the divide: ({v.X},{v.Y})");
}
}
[Fact]
public void ProjectToClipLease_ReusesPooledWorkWithoutResultArrays()
{
var opening = new[]
{
new Vector3(-1f, -1f, -3f),
new Vector3(1f, -1f, -3f),
new Vector3(1f, 1f, -3f),
new Vector3(-1f, 1f, -3f),
};
Matrix4x4 viewProjection = ViewProj();
using (PortalProjection.ClipPolygonLease warm =
PortalProjection.ProjectToClipLease(
opening,
Matrix4x4.Identity,
viewProjection))
{
Assert.Equal(4, warm.Count);
}
long before = GC.GetAllocatedBytesForCurrentThread();
int totalVertices = 0;
for (int i = 0; i < 1_000; i++)
{
using PortalProjection.ClipPolygonLease lease =
PortalProjection.ProjectToClipLease(
opening,
Matrix4x4.Identity,
viewProjection);
totalVertices += lease.Count;
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(4_000, totalVertices);
// A tiered-JIT/ArrayPool bookkeeping transition can contribute a few hundred fixed bytes
// to the first measured batch on some runtimes. Keep the ceiling far below the former
// per-call result-array regression (~448 KB / 1,000 calls), while accepting that fixed
// process noise so this test measures linear hot-path allocation rather than JIT timing.
Assert.True(allocated <= 1_024, $"pooled projections allocated {allocated:N0} bytes");
}
[Fact]
public void ClipToRegion_FrameOwnedStore_ReusesExactResultArray()
{
var opening = new[]
{
new Vector3(-1f, -1f, -5f),
new Vector3(1f, -1f, -5f),
new Vector3(1f, 1f, -5f),
new Vector3(-1f, 1f, -5f),
};
Vector4[] subject = PortalProjection.ProjectToClip(
opening, Matrix4x4.Identity, ViewProj());
Vector2[] region = FullScreenCcw();
Vector2[] expected = PortalProjection.ClipToRegion(subject, region);
var store = new PortalPolygonVertexStore();
Vector2[] first = PortalProjection.ClipToRegion(
subject.AsSpan(), region, store);
Vector2[] firstSnapshot = (Vector2[])first.Clone();
int allocationHighWater = store.AllocationCount;
store.ResetUsage();
Vector2[] second = PortalProjection.ClipToRegion(
subject.AsSpan(), region, store);
Assert.Same(first, second);
Assert.Equal(allocationHighWater, store.AllocationCount);
Assert.Equal(expected, firstSnapshot);
Assert.Equal(expected, second);
long before = GC.GetAllocatedBytesForCurrentThread();
float checksum = 0f;
for (int i = 0; i < 1_000; i++)
{
store.ResetUsage();
Vector2[] reused = PortalProjection.ClipToRegion(
subject.AsSpan(), region, store);
checksum += reused[0].X;
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(float.IsFinite(checksum));
Assert.True(allocated <= 4_096,
$"frame-owned portal clipping allocated {allocated:N0} bytes");
}
[Fact]
public void ProjectToNdc_SecondRentFailure_ReturnsFirstBuffer()
{
var pool = new ThrowOnSecondRentPool<Vector4>();
Vector3[] opening =
[
new(-1f, -1f, -5f),
new(1f, -1f, -5f),
new(1f, 1f, -5f),
new(-1f, 1f, -5f),
];
Assert.Throws<InjectedRentException>(() =>
PortalProjection.ProjectToNdc(
opening,
Matrix4x4.Identity,
ViewProj(),
pool));
Assert.Equal(0, pool.OutstandingCount);
}
[Fact]
public void ProjectToClipLease_SecondRentFailure_ReturnsFirstBuffer()
{
var pool = new ThrowOnSecondRentPool<Vector4>();
Vector3[] opening =
[
new(-1f, -1f, -5f),
new(1f, -1f, -5f),
new(1f, 1f, -5f),
new(-1f, 1f, -5f),
];
try
{
using PortalProjection.ClipPolygonLease _ =
PortalProjection.ProjectToClipLease(
opening,
Matrix4x4.Identity,
ViewProj(),
pool);
Assert.Fail("The injected second-rent failure was not observed.");
}
catch (InjectedRentException)
{
// Expected: ownership of the first rent must already be reconciled.
}
Assert.Equal(0, pool.OutstandingCount);
}
[Fact]
public void ClipToRegion_SecondRentFailure_ReturnsFirstBuffer()
{
var pool = new ThrowOnSecondRentPool<Vector4>();
Vector4[] subject =
[
new(-1f, -1f, 0f, 1f),
new(1f, -1f, 0f, 1f),
new(1f, 1f, 0f, 1f),
new(-1f, 1f, 0f, 1f),
];
Assert.Throws<InjectedRentException>(() =>
PortalProjection.ClipToRegion(
subject.AsSpan(),
FullScreenCcw(),
new PortalPolygonVertexStore(),
pool));
Assert.Equal(0, pool.OutstandingCount);
}
[Fact]
public void ClipPolygonLease_AccessAfterDispose_FailsFast()
{
var pool = new ThrowOnSecondRentPool<Vector4>(throwOnRent: int.MaxValue);
Vector3[] opening =
[
new(-1f, -1f, -5f),
new(1f, -1f, -5f),
new(1f, 1f, -5f),
new(-1f, 1f, -5f),
];
PortalProjection.ClipPolygonLease lease =
PortalProjection.ProjectToClipLease(
opening,
Matrix4x4.Identity,
ViewProj(),
pool);
lease.Dispose();
Assert.Equal(0, pool.OutstandingCount);
try
{
_ = lease.Count;
Assert.Fail("A disposed pooled lease exposed its returned buffer.");
}
catch (ObjectDisposedException)
{
// Expected.
}
try
{
_ = lease.Span.Length;
Assert.Fail("A disposed pooled lease exposed its returned span.");
}
catch (ObjectDisposedException)
{
// Expected.
}
// Same-instance disposal is idempotent and must not return buffers twice.
lease.Dispose();
Assert.Equal(0, pool.OutstandingCount);
}
private sealed class InjectedRentException : Exception { }
private sealed class ThrowOnSecondRentPool<T>(int throwOnRent = 2) : ArrayPool<T>
{
private int _rentCount;
internal int OutstandingCount { get; private set; }
public override T[] Rent(int minimumLength)
{
_rentCount++;
if (_rentCount == throwOnRent)
throw new InjectedRentException();
OutstandingCount++;
return new T[Math.Max(1, minimumLength)];
}
public override void Return(T[] array, bool clearArray = false)
{
Assert.NotNull(array);
Assert.True(OutstandingCount > 0, "A pooled array was returned twice.");
OutstandingCount--;
}
}
}

View file

@ -39,6 +39,198 @@ public class PortalVisibilityBuilderTests
private static PortalVisibilityFrame Build(LoadedCell cam, Dictionary<uint, LoadedCell> all)
=> PortalVisibilityBuilder.Build(cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, ViewProj());
[Fact]
public void Build_ReusedFrame_NestedGraph_MatchesFreshAndStabilizesScratch()
{
var root = Cell(0x0001,
new CellPortalInfo(0x0002, 0, 0, 0),
new CellPortalInfo(0x0003, 1, 0, 0));
root.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -2f));
root.PortalPolygons.Add(QuadX(0.05f, 0.8f, -3f));
var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0));
left.PortalPolygons.Add(QuadX(-0.8f, -0.05f, -5f));
var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0));
right.PortalPolygons.Add(QuadX(0.05f, 0.8f, -5f));
var exit = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0));
exit.PortalPolygons.Add(QuadX(-0.8f, 0.8f, -8f));
var cells = new Dictionary<uint, LoadedCell>
{
[root.CellId] = root,
[left.CellId] = left,
[right.CellId] = right,
[exit.CellId] = exit,
};
LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null;
PortalVisibilityFrame fresh = PortalVisibilityBuilder.Build(
root, Vector3.Zero, Lookup, ViewProj());
uint[] expectedOrder = fresh.OrderedVisibleCells.ToArray();
uint[] expectedCells = fresh.CellViews.Keys.OrderBy(id => id).ToArray();
int expectedOutsidePolygons = fresh.OutsideView.Polygons.Count;
var reuse = new PortalVisibilityFrame();
PortalVisibilityBuilder.Build(root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
int scratchHighWater = reuse.ClipRegionScratchCount;
int vertexArrayHighWater = reuse.PolygonVertexAllocationCount;
Assert.True(scratchHighWater > 0);
Assert.True(vertexArrayHighWater > 0);
for (int frame = 0; frame < 16; frame++)
{
PortalVisibilityFrame actual = PortalVisibilityBuilder.Build(
root, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
Assert.Same(reuse, actual);
Assert.Equal(expectedOrder, actual.OrderedVisibleCells);
Assert.Equal(expectedCells, actual.CellViews.Keys.OrderBy(id => id).ToArray());
Assert.Equal(expectedOutsidePolygons, actual.OutsideView.Polygons.Count);
Assert.Equal(scratchHighWater, reuse.ClipRegionScratchCount);
Assert.Equal(vertexArrayHighWater, reuse.PolygonVertexAllocationCount);
Assert.Empty(reuse.TodoScratch);
}
}
[Fact]
public void Build_ReusedFrame_AfterLookupException_HasNoStaleWorkOrViews()
{
var root = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
root.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f));
var reuse = new PortalVisibilityFrame();
Assert.Throws<InvalidOperationException>(() => PortalVisibilityBuilder.Build(
root,
Vector3.Zero,
_ => throw new InvalidOperationException("fixture lookup failure"),
ViewProj(),
reuseFrame: reuse));
var isolatedRoot = Cell(0x0010);
PortalVisibilityFrame recovered = PortalVisibilityBuilder.Build(
isolatedRoot, Vector3.Zero, _ => null, ViewProj(), reuseFrame: reuse);
Assert.Same(reuse, recovered);
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.OrderedVisibleCells);
Assert.Equal(new[] { isolatedRoot.CellId }, recovered.CellViews.Keys);
Assert.Empty(recovered.CrossBuildingViews);
Assert.True(recovered.OutsideView.IsEmpty);
Assert.Empty(reuse.TodoScratch);
}
[Fact]
public void ResetForBuild_DropsPathologicalContainerHighWater_AfterIdleHysteresis()
{
const int pathologicalCount =
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity * 4;
var frame = new PortalVisibilityFrame();
var placeholder = new LoadedCell();
for (int i = 0; i < pathologicalCount; i++)
{
uint id = (uint)i + 1u;
frame.CellViews.Add(id, new CellView());
frame.CrossBuildingViews.Add(id, new CellView());
frame.OrderedVisibleCells.Add(id);
frame.QueuedScratch.Add(id);
frame.DrawListedScratch.Add(id);
frame.ProcessedViewCountsScratch.Add(id, i);
frame.TodoScratch.Add((placeholder, i));
}
Assert.True(
frame.CellViewMapCapacity
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.True(
frame.QueuedCapacity
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
frame.ResetForBuild();
Assert.Empty(frame.CellViews);
Assert.Empty(frame.CrossBuildingViews);
Assert.Empty(frame.OrderedVisibleCells);
Assert.Empty(frame.QueuedScratch);
Assert.Empty(frame.DrawListedScratch);
Assert.Empty(frame.ProcessedViewCountsScratch);
Assert.Empty(frame.TodoScratch);
Assert.InRange(
frame.RetainedCellViewCount,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.True(
frame.CellViewMapCapacity
> PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
for (int i = 0; i < PortalVisibilityFrame.CapacityTrimIdleFrames; i++)
frame.ResetForBuild();
Assert.InRange(
frame.CellViewMapCapacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.CrossBuildingViewMapCapacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.QueuedCapacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.DrawListedCapacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.ProcessedViewCountCapacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.OrderedVisibleCells.Capacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
Assert.InRange(
frame.TodoScratch.Capacity,
0,
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity);
LoadedCell root = Cell(0x1000);
PortalVisibilityFrame rebuilt = PortalVisibilityBuilder.Build(
root,
Vector3.Zero,
_ => null,
ViewProj(),
reuseFrame: frame);
Assert.Same(frame, rebuilt);
Assert.Equal(new[] { root.CellId }, rebuilt.OrderedVisibleCells);
Assert.True(rebuilt.CellViews.ContainsKey(root.CellId));
}
[Fact]
public void ResetForBuild_RecurringLargeFlood_PreservesWarmCapacity()
{
const int recurringCount =
PortalVisibilityFrame.MaxRetainedBuildCollectionCapacity + 163;
var frame = new PortalVisibilityFrame();
for (int iteration = 0;
iteration < PortalVisibilityFrame.CapacityTrimIdleFrames + 5;
iteration++)
{
for (int i = 0; i < recurringCount; i++)
{
uint id = (uint)i + 1u;
frame.QueuedScratch.Add(id);
frame.OrderedVisibleCells.Add(id);
}
int queuedCapacity = frame.QueuedCapacity;
int orderedCapacity = frame.OrderedVisibleCells.Capacity;
frame.ResetForBuild();
Assert.Equal(queuedCapacity, frame.QueuedCapacity);
Assert.Equal(orderedCapacity, frame.OrderedVisibleCells.Capacity);
}
}
[Fact]
public void Builder_Cellar_WindowClippedToStairwell_NotFullWindow()
{
@ -627,6 +819,40 @@ public class PortalVisibilityBuilderTests
"exterior seed should be clipped to the door opening, not full-screen");
}
[Fact]
public void BuildFromExterior_ReusedFrame_ClearsPriorOutputAndProducesSameResult()
{
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
room.ClipPlanes.Add(new PortalClipPlane
{
Normal = new Vector3(0f, 0f, 1f),
D = 3f,
InsideSide = 1,
});
var reuse = new PortalVisibilityFrame();
LoadedCell? Lookup(uint id) => id == room.CellId ? room : null;
var first = PortalVisibilityBuilder.BuildFromExterior(
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
Assert.Same(reuse, first);
Assert.Equal(new[] { room.CellId }, first.OrderedVisibleCells);
var empty = PortalVisibilityBuilder.BuildFromExterior(
Array.Empty<LoadedCell>(), Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
Assert.Same(reuse, empty);
Assert.Empty(empty.OrderedVisibleCells);
Assert.Empty(empty.CellViews);
Assert.True(empty.OutsideView.IsEmpty);
var second = PortalVisibilityBuilder.BuildFromExterior(
new[] { room }, Vector3.Zero, Lookup, ViewProj(), reuseFrame: reuse);
Assert.Same(reuse, second);
Assert.Equal(new[] { room.CellId }, second.OrderedVisibleCells);
Assert.True(second.CellViews.TryGetValue(room.CellId, out CellView? view));
Assert.False(view!.IsEmpty);
}
[Fact]
public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide()
{

View file

@ -0,0 +1,64 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Tests.Rendering;
public sealed class RendererResourceDisposalLedgerTests
{
[Fact]
public void FailedGpuResourceDoesNotStrandSiblingAndFinalizesOnlyAfterRetry()
{
int firstDeleteCalls = 0;
int firstAccountingCalls = 0;
int firstMetadataCalls = 0;
int siblingDeleteCalls = 0;
bool failAccounting = true;
bool disposed = false;
var first = new RetryableGpuResourceRelease(
() => firstDeleteCalls++,
() =>
{
firstAccountingCalls++;
if (failAccounting)
{
failAccounting = false;
throw new InvalidOperationException("injected accounting failure");
}
},
() => firstMetadataCalls++);
var sibling = new RetryableGpuResourceRelease(
() => siblingDeleteCalls++);
var ledger = new RetryableResourceReleaseLedger(
[
("first", first.Run),
("sibling", sibling.Run),
]);
void AdvanceOwnerDispose()
{
ResourceReleaseAttempt attempt = ledger.Advance();
if (ledger.IsComplete)
disposed = true;
if (attempt.HasFailures)
throw attempt.ToException("Synthetic renderer teardown failed.");
}
Assert.Throws<AggregateException>(AdvanceOwnerDispose);
Assert.False(disposed);
Assert.Equal(1, firstDeleteCalls);
Assert.Equal(1, firstAccountingCalls);
Assert.Equal(0, firstMetadataCalls);
Assert.Equal(1, siblingDeleteCalls);
AdvanceOwnerDispose();
AdvanceOwnerDispose();
Assert.True(disposed);
Assert.Equal(1, firstDeleteCalls);
Assert.Equal(2, firstAccountingCalls);
Assert.Equal(1, firstMetadataCalls);
Assert.Equal(1, siblingDeleteCalls);
}
}

View file

@ -0,0 +1,81 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class ResourceShutdownTransactionTests
{
[Fact]
public void AttemptsIndependentOperationsAndRetriesOnlyPendingOnes()
{
var calls = new List<string>();
int retryAttempts = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owners",
[
new("first", () => calls.Add("first")),
new("retry", () =>
{
calls.Add("retry");
if (retryAttempts++ == 0)
throw new InvalidOperationException("synthetic transient failure");
}),
new("last", () => calls.Add("last")),
]),
new ResourceShutdownStage("dependent",
[
new("dependent", () => calls.Add("dependent")),
]));
transaction.CompleteOrThrow();
Assert.Equal(["first", "retry", "last", "retry", "dependent"], calls);
Assert.True(transaction.IsComplete);
}
[Fact]
public void PersistentFailureNeverRunsDependentStage()
{
int failures = 0;
int dependentCalls = 0;
var transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("owner",
[
new("persistent", () =>
{
failures++;
throw new InvalidOperationException("persistent");
}),
]),
new ResourceShutdownStage("dependent",
[
new("must-not-run", () => dependentCalls++),
]));
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
Assert.Equal(2, failures);
Assert.Equal(0, dependentCalls);
Assert.False(transaction.IsComplete);
}
[Fact]
public void ReentrantCompletionDoesNotReplayActiveOperation()
{
ResourceShutdownTransaction? transaction = null;
int calls = 0;
transaction = new ResourceShutdownTransaction(
new ResourceShutdownStage("stage",
[
new("reentrant", () =>
{
calls++;
transaction!.CompleteOrThrow();
}),
]));
transaction.CompleteOrThrow();
Assert.Equal(1, calls);
Assert.True(transaction.IsComplete);
}
}

View file

@ -33,6 +33,8 @@ public sealed class RetailAlphaQueueTests
log);
Assert.Equal(1, objects.ResetCount);
Assert.Equal(1, particles.ResetCount);
Assert.Equal(1, objects.PrepareCount);
Assert.Equal(1, particles.PrepareCount);
}
[Fact]
@ -115,12 +117,20 @@ public sealed class RetailAlphaQueueTests
{
public List<int> BatchSizes { get; } = new();
public int ResetCount { get; private set; }
public int PrepareCount { get; private set; }
private int[] _prepared = [];
public void DrawAlphaBatch(ReadOnlySpan<int> tokens)
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
{
BatchSizes.Add(tokens.Length);
foreach (int token in tokens)
log.Add($"{name}:{token}");
PrepareCount++;
_prepared = tokens.ToArray();
}
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
BatchSizes.Add(drawCount);
for (int i = 0; i < drawCount; i++)
log.Add($"{name}:{_prepared[firstPreparedDraw + i]}");
}
public void ResetAlphaSubmissions() => ResetCount++;

View file

@ -1,4 +1,5 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.Content.Vfx;
using AcDream.Core.Meshing;
using AcDream.Core.Vfx;
@ -78,6 +79,115 @@ public sealed class RetailParticleGeometryClassifierTests
Assert.Equal(new[] { 0x01001666u, 0x01001667u }, decrements);
}
[Fact]
public void MeshReferenceTracker_retriesAcquireThatFailedBeforeCommit()
{
int attempts = 0;
var increments = new List<uint>();
using var tracker = new ParticleMeshReferenceTracker(
id =>
{
attempts++;
if (attempts == 1)
throw new InvalidOperationException("before commit");
increments.Add(id);
},
_ => { });
Assert.Throws<InvalidOperationException>(() => tracker.Register(7, 0x01001666u));
tracker.Register(7, 0x01001666u);
Assert.Equal([0x01001666u], increments);
}
[Fact]
public void MeshReferenceTracker_doesNotRepeatAcquireThatCommittedBeforeThrowing()
{
int attempts = 0;
using var tracker = new ParticleMeshReferenceTracker(
_ =>
{
attempts++;
throw new MeshReferenceMutationException(
"after commit",
mutationCommitted: true,
new InvalidOperationException());
},
_ => { });
Assert.Throws<MeshReferenceMutationException>(() => tracker.Register(7, 0x01001666u));
tracker.Register(7, 0x01001666u);
Assert.Equal(1, attempts);
}
[Fact]
public void MeshReferenceTracker_retriesReleaseWithoutRepeatingCommittedMutation()
{
int attempts = 0;
var tracker = new ParticleMeshReferenceTracker(_ => { }, _ =>
{
attempts++;
if (attempts == 1)
throw new InvalidOperationException("before commit");
});
tracker.Register(7, 0x01001666u);
Assert.Throws<InvalidOperationException>(() => tracker.Release(7));
tracker.Release(7);
tracker.Release(7);
Assert.Equal(2, attempts);
tracker.Dispose();
}
[Fact]
public void MeshReferenceTracker_disposeAttemptsEveryOwnerAndCanResume()
{
bool failFirst = true;
var releases = new List<uint>();
var tracker = new ParticleMeshReferenceTracker(_ => { }, id =>
{
if (id == 0x01001666u && failFirst)
{
failFirst = false;
throw new InvalidOperationException("before commit");
}
releases.Add(id);
});
tracker.Register(7, 0x01001666u);
tracker.Register(8, 0x01001667u);
Assert.Throws<AggregateException>(() => tracker.Dispose());
Assert.Contains(0x01001667u, releases);
tracker.Dispose();
Assert.Equal(1, releases.Count(id => id == 0x01001666u));
Assert.Equal(1, releases.Count(id => id == 0x01001667u));
}
[Fact]
public void MeshReferenceTracker_releaseReentryDuringAcquireReconcilesWithoutLeak()
{
ParticleMeshReferenceTracker? tracker = null;
int increments = 0;
int decrements = 0;
tracker = new ParticleMeshReferenceTracker(
_ =>
{
increments++;
tracker!.Release(7);
},
_ => decrements++);
tracker.Register(7, 0x01001666u);
tracker.Release(7);
tracker.Dispose();
Assert.Equal(1, increments);
Assert.Equal(1, decrements);
}
[Fact]
public void BlendResolver_corruptSurfaceFallsBackAndReportsItsDid()
{

View file

@ -0,0 +1,338 @@
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class StandaloneBindlessTextureCacheTests
{
[Fact]
public void SharedSurfaceRemainsLiveUntilFinalEmitterOwnerLeaves()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(backend, retirements);
StandaloneBindlessTextureResource resource = Resource(1, bytes: 4);
cache.AddAndAcquire(10, resource);
Assert.True(cache.TryAcquire(20, 1, out StandaloneBindlessTextureResource? shared));
Assert.Same(resource, shared);
cache.ReleaseOwner(10);
Assert.Equal(1, cache.ActiveResourceCount);
Assert.Equal(0, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
cache.ReleaseOwner(20);
Assert.Equal(0, cache.ActiveResourceCount);
Assert.Equal(1, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
Assert.True(cache.TryAcquire(30, 1, out StandaloneBindlessTextureResource? reused));
Assert.Same(resource, reused);
Assert.Equal(0, cache.UnownedEntryCount);
Assert.Empty(retirements.Actions);
}
[Fact]
public void UnownedCountBoundEvictsOldestAndDefersPhysicalRetirement()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 2);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
AddAndRelease(cache, ownerId: 30, Resource(3, bytes: 4));
Assert.Equal(3, cache.EntryCount);
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Equal(2, cache.EntryCount);
Assert.Equal(2, cache.UnownedEntryCount);
Assert.Single(retirements.Actions);
Assert.Empty(backend.Events);
Assert.False(cache.TryAcquire(40, 1, out _));
Assert.True(cache.TryAcquire(40, 2, out _));
retirements.Drain();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
[Fact]
public void UnownedByteBoundNeverEvictsALiveEmitterTexture()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 8,
maximumUnownedCount: 10);
cache.AddAndAcquire(10, Resource(1, bytes: 8));
cache.AddAndAcquire(20, Resource(2, bytes: 8));
cache.ReleaseOwner(10);
Assert.Equal(2, cache.EntryCount);
Assert.Equal(8, cache.UnownedBytes);
Assert.Empty(retirements.Actions);
cache.AddAndAcquire(30, Resource(3, bytes: 8));
cache.ReleaseOwner(30);
Assert.Equal(3, cache.EntryCount);
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Equal(2, cache.EntryCount);
Assert.Equal(8, cache.UnownedBytes);
Assert.Single(retirements.Actions);
Assert.True(cache.TryAcquire(40, 2, out _));
Assert.False(cache.TryAcquire(40, 1, out _));
}
[Fact]
public void RepeatedAcquireByOneEmitterIsIdempotent()
{
var backend = new FakeBackend();
using var cache = CreateCache(backend, new DeferredRetirementQueue());
StandaloneBindlessTextureResource resource = Resource(1, bytes: 4);
cache.AddAndAcquire(10, resource);
Assert.True(cache.TryAcquire(10, 1, out _));
Assert.True(cache.TryAcquire(10, 1, out _));
Assert.Equal(1, cache.OwnerCount);
Assert.Equal(1, cache.ActiveResourceCount);
cache.ReleaseOwner(10);
Assert.Equal(0, cache.OwnerCount);
Assert.Equal(0, cache.ActiveResourceCount);
Assert.Equal(1, cache.UnownedEntryCount);
}
[Fact]
public void PortalScaleOwnerReleaseQueuesOnlyThePerFrameEvictionBudget()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 2);
for (uint id = 1; id <= 100; id++)
AddAndRelease(cache, ownerId: id, Resource(id, bytes: 4));
Assert.Empty(retirements.Actions);
cache.Tick();
Assert.Single(retirements.Actions);
cache.Tick(maximumEvictions: 3);
Assert.Equal(4, retirements.Actions.Count);
Assert.Equal(96, cache.EntryCount);
}
[Fact]
public void FailedRetirementQueueAdmissionRetainsReleaseForPublicationRetry()
{
var backend = new FakeBackend();
var retirements = new DeferredRetirementQueue { FailNextAdmission = true };
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 1);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
Assert.Throws<InvalidOperationException>(() => cache.Tick());
Assert.Equal(1, cache.EntryCount);
Assert.Equal(1, cache.UnownedEntryCount);
Assert.Equal(1, cache.AwaitingRetirementPublicationCount);
cache.Tick();
Assert.Equal(1, cache.EntryCount);
Assert.Single(retirements.Actions);
Assert.Equal(0, cache.AwaitingRetirementPublicationCount);
}
[Fact]
public void DeleteFailureDoesNotRepeatCommittedResidencyRelease()
{
var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 };
var retirements = new DeferredRetirementQueue();
using var cache = CreateCache(
backend,
retirements,
unownedBudgetBytes: 1_000,
maximumUnownedCount: 1);
AddAndRelease(cache, ownerId: 10, Resource(1, bytes: 4));
AddAndRelease(cache, ownerId: 20, Resource(2, bytes: 4));
cache.Tick();
Assert.Throws<InvalidOperationException>(() => retirements.DrainOne());
retirements.Drain();
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1"));
Assert.Equal(2, backend.Events.Count(static e => e == "delete:1"));
}
[Fact]
public void DisposeMakesEveryHandleNonResidentBeforeDeletingAnyTexture()
{
var backend = new FakeBackend();
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
cache.Dispose();
Assert.Equal(
["nonresident:1", "nonresident:2", "delete:1", "delete:2"],
backend.Events);
}
[Fact]
public void FailedResidencyReleaseNeverDeletesThatBackingTexture()
{
var backend = new FakeBackend { FailNonResidentSurfaceId = 1 };
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
AggregateException failure = Assert.Throws<AggregateException>(() => cache.Dispose());
Assert.Contains("surface 1", failure.ToString());
Assert.DoesNotContain("delete:1", backend.Events);
Assert.Contains("delete:2", backend.Events);
}
[Fact]
public void DisposeRetriesOnlyTheFailedDeleteStage()
{
var backend = new FakeBackend { FailDeleteSurfaceIdOnce = 1 };
var cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.AddAndAcquire(20, Resource(2, bytes: 4));
Assert.Throws<AggregateException>(() => cache.Dispose());
cache.Dispose();
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:1"));
Assert.Equal(1, backend.Events.Count(static e => e == "nonresident:2"));
Assert.Equal(2, backend.Events.Count(static e => e == "delete:1"));
Assert.Equal(1, backend.Events.Count(static e => e == "delete:2"));
}
[Fact]
public void BackendDisposeReentryObservesActiveTransactionWithoutRepeatingStages()
{
var backend = new FakeBackend();
StandaloneBindlessTextureCache? cache = null;
backend.OnMutation = _ => cache!.Dispose();
cache = CreateCache(backend, new DeferredRetirementQueue());
cache.AddAndAcquire(10, Resource(1, bytes: 4));
cache.Dispose();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
cache.Dispose();
Assert.Equal(["nonresident:1", "delete:1"], backend.Events);
}
private static StandaloneBindlessTextureCache CreateCache(
FakeBackend backend,
DeferredRetirementQueue retirements,
long unownedBudgetBytes = 64,
int maximumUnownedCount = 8) =>
new(
backend,
retirements,
unownedBudgetBytes,
maximumUnownedCount);
private static void AddAndRelease(
StandaloneBindlessTextureCache cache,
uint ownerId,
StandaloneBindlessTextureResource resource)
{
cache.AddAndAcquire(ownerId, resource);
cache.ReleaseOwner(ownerId);
}
private static StandaloneBindlessTextureResource Resource(uint surfaceId, long bytes) =>
new()
{
SurfaceId = surfaceId,
Name = surfaceId,
Handle = surfaceId + 1_000UL,
Bytes = bytes,
};
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
{
public bool FailNextAdmission { get; set; }
public Queue<Action> Actions { get; } = new();
public void Retire(Action release)
{
if (FailNextAdmission)
{
FailNextAdmission = false;
throw new InvalidOperationException("retirement queue full");
}
Actions.Enqueue(release);
}
public void Drain()
{
while (Actions.TryDequeue(out Action? release))
release();
}
public void DrainOne()
{
Assert.True(Actions.TryDequeue(out Action? release));
try
{
release();
}
catch
{
Actions.Enqueue(release);
throw;
}
}
}
private sealed class FakeBackend : IStandaloneBindlessTextureBackend
{
public uint FailNonResidentSurfaceId { get; init; }
public uint FailDeleteSurfaceIdOnce { get; set; }
public List<string> Events { get; } = [];
public Action<string>? OnMutation { get; set; }
public void MakeNonResident(StandaloneBindlessTextureResource resource)
{
Events.Add($"nonresident:{resource.SurfaceId}");
OnMutation?.Invoke("nonresident");
if (resource.SurfaceId == FailNonResidentSurfaceId)
throw new InvalidOperationException($"Could not release surface {resource.SurfaceId}.");
}
public void Delete(StandaloneBindlessTextureResource resource)
{
Events.Add($"delete:{resource.SurfaceId}");
OnMutation?.Invoke("delete");
if (resource.SurfaceId == FailDeleteSurfaceIdOnce)
{
FailDeleteSurfaceIdOnce = 0;
throw new InvalidOperationException($"Could not delete surface {resource.SurfaceId}.");
}
}
}
}

View file

@ -727,6 +727,7 @@ public sealed class EntityEffectControllerTests
WorldEntity entity = fixture.ReadyLive();
fixture.Controller.HandleDirect(new PlayPhysicsScript(Guid, DirectDid));
entity.SetPosition(new Vector3(10, 20, 30));
fixture.Controller.MarkLiveOwnerPoseDirty(Guid);
fixture.Controller.RefreshLiveOwnerPoses();
fixture.Runner.Tick(1.0);
@ -734,6 +735,84 @@ public sealed class EntityEffectControllerTests
Assert.Equal(new Vector3(10, 20, 30), Assert.Single(fixture.Sink.Calls).Position);
}
[Fact]
public void PoseRefreshWorkset_VisitsOnlyDirtyOwnerAcrossDenseReadySet()
{
const int ownerCount = 2_000;
var fixture = new Fixture();
WorldEntity? changedEntity = null;
uint changedGuid = 0;
for (int i = 0; i < ownerCount; i++)
{
uint guid = 0x71000000u + (uint)i;
WorldEntity entity = fixture.ReadyLive(guid);
if (i == 777)
{
changedGuid = guid;
changedEntity = entity;
}
}
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(0, fixture.Controller.LastPoseRefreshOwnerVisitCount);
changedEntity!.SetPosition(new Vector3(7, 8, 9));
fixture.Controller.MarkLiveOwnerPoseDirty(changedGuid);
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount);
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(0, fixture.Controller.LastPoseRefreshOwnerVisitCount);
}
[Fact]
public void PoseRefreshWorkset_RetainsCallbackMutationForNextPass()
{
const uint firstGuid = 0x72000001u;
const uint secondGuid = 0x72000002u;
var fixture = new Fixture();
WorldEntity first = fixture.ReadyLive(firstGuid);
WorldEntity second = fixture.ReadyLive(secondGuid);
fixture.Poses.Publish(first, Array.Empty<Matrix4x4>());
fixture.Poses.Publish(second, Array.Empty<Matrix4x4>());
fixture.Controller.RefreshLiveOwnerPoses();
first.SetPosition(new Vector3(10, 0, 0));
bool callbackRan = false;
fixture.Poses.EffectPoseChanged += localId =>
{
if (localId != first.Id || callbackRan)
return;
callbackRan = true;
fixture.Controller.MarkLiveOwnerPoseDirty(secondGuid);
};
fixture.Controller.MarkLiveOwnerPoseDirty(firstGuid);
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount);
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount);
Assert.True(callbackRan);
}
[Fact]
public void PoseRefreshWorkset_RejectsDeletedIncarnationAfterGuidReuse()
{
var fixture = new Fixture();
fixture.ReadyLive(Guid, generation: 1);
fixture.Controller.MarkLiveOwnerPoseDirty(Guid);
Assert.True(fixture.Runtime.UnregisterLiveEntity(
new DeleteObject.Parsed(Guid, 1),
isLocalPlayer: false));
WorldEntity replacement = fixture.ReadyLive(Guid, generation: 2);
replacement.SetPosition(new Vector3(20, 30, 40));
fixture.Controller.MarkLiveOwnerPoseDirty(Guid);
fixture.Controller.RefreshLiveOwnerPoses();
Assert.Equal(1, fixture.Controller.LastPoseRefreshOwnerVisitCount);
}
[Fact]
public void ClearNetworkStatePreservesDatStaticProfiles()
{

View file

@ -154,6 +154,31 @@ public sealed class EntityEffectPoseRegistryTests
Assert.Equal(0xA9B4000Bu, cellId);
}
[Fact]
public void ChangeNotifications_SuppressEquivalentStaticPublishes()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(13u, new Vector3(1, 2, 3));
var changed = new List<uint>();
poses.EffectPoseChanged += changed.Add;
Matrix4x4 part = Matrix4x4.CreateTranslation(0, 0, 1);
poses.Publish(entity, new[] { part });
poses.Publish(entity, new[] { part });
Assert.True(poses.UpdateRoot(entity));
Assert.Equal(new[] { 13u }, changed);
entity.SetPosition(new Vector3(4, 5, 6));
Assert.True(poses.UpdateRoot(entity));
Assert.Equal(new[] { 13u, 13u }, changed);
poses.Publish(entity, new[] { Matrix4x4.CreateTranslation(0, 0, 2) });
Assert.Equal(new[] { 13u, 13u, 13u }, changed);
Assert.True(poses.Remove(entity.Id));
Assert.Equal(new[] { 13u, 13u, 13u, 13u }, changed);
}
private static WorldEntity Entity(uint id, Vector3 position) => new()
{
Id = id,

View file

@ -0,0 +1,60 @@
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public sealed class ViewconeCullerReuseTests
{
[Fact]
public void ReusedCuller_ReplacesPriorCellAndOutsideState()
{
const uint firstCell = 0xA9B40100;
const uint secondCell = 0xA9B40101;
var clipFrame = ClipFrame.NoClip();
var assemblyScratch = new ClipFrameAssembly();
var culler = new ViewconeCuller();
var first = Frame(firstCell, includeOutside: true);
ClipFrameAssembly firstAssembly = ClipFrameAssembler.Assemble(
clipFrame,
first,
assemblyScratch);
Assert.Same(culler, ViewconeCuller.Build(firstAssembly, Matrix4x4.Identity, culler));
Assert.True(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
Assert.True(culler.SphereVisibleOutside(Vector3.Zero, 0f));
var second = Frame(secondCell, includeOutside: false);
ClipFrameAssembly secondAssembly = ClipFrameAssembler.Assemble(
clipFrame,
second,
assemblyScratch);
Assert.Same(culler, ViewconeCuller.Build(secondAssembly, Matrix4x4.Identity, culler));
Assert.False(culler.SphereVisibleInCell(firstCell, Vector3.Zero, 0f));
Assert.True(culler.SphereVisibleInCell(secondCell, Vector3.Zero, 0f));
Assert.False(culler.SphereVisibleInCell(secondCell, new Vector3(2f, 0f, 0f), 0f));
Assert.False(culler.SphereVisibleOutside(Vector3.Zero, 0f));
}
private static PortalVisibilityFrame Frame(uint cellId, bool includeOutside)
{
var result = new PortalVisibilityFrame();
var cellView = new CellView();
cellView.Add(Square(0.5f));
result.CellViews.Add(cellId, cellView);
result.OrderedVisibleCells.Add(cellId);
if (includeOutside)
result.OutsideView.Add(Square(0.5f));
return result;
}
private static ViewPolygon Square(float half)
=> new(new[]
{
new Vector2(-half, -half),
new Vector2(half, -half),
new Vector2(half, half),
new Vector2(-half, half),
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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