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

@ -16,6 +16,7 @@
<ItemGroup>
<Using Include="Xunit" />
<Using Include="AcDream.App.Tests.BoundedTestDatCollection" Alias="DatCollection" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,76 @@
using AcDream.Content;
using AcDream.Core.Content;
using DatReaderWriter.Lib.IO;
using DatReaderWriter.Options;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace AcDream.App.Tests;
/// <summary>
/// Test-only raw DatCollection which can be passed through the same bounded
/// abstraction as production without changing DAT fixture setup code.
/// </summary>
public sealed class BoundedTestDatCollection : DatReaderWriter.DatCollection, IDatReaderWriter
{
private readonly DatCollectionAdapter _bounded;
public BoundedTestDatCollection(
string datDirectory,
DatAccessType datAccessType = DatAccessType.Read)
: base(new DatCollectionOptions
{
DatDirectory = datDirectory,
AccessType = datAccessType,
IndexCachingStrategy = IndexCachingStrategy.OnDemand,
FileCachingStrategy = FileCachingStrategy.Never,
})
{
_bounded = new DatCollectionAdapter(this);
}
public BoundedTestDatCollection(DatCollectionOptions options)
: base(options)
{
_bounded = new DatCollectionAdapter(this);
}
string IDatReaderWriter.SourceDirectory => _bounded.SourceDirectory;
IDatDatabase IDatReaderWriter.Portal => _bounded.Portal;
IDatDatabase IDatReaderWriter.Cell => _bounded.Cell;
ReadOnlyDictionary<uint, IDatDatabase> IDatReaderWriter.CellRegions => _bounded.CellRegions;
IDatDatabase IDatReaderWriter.HighRes => _bounded.HighRes;
IDatDatabase IDatReaderWriter.Language => _bounded.Language;
IDatDatabase IDatReaderWriter.Local => _bounded.Local;
ReadOnlyDictionary<uint, uint> IDatReaderWriter.RegionFileMap => _bounded.RegionFileMap;
int IDatReaderWriter.PortalIteration => _bounded.PortalIteration;
int IDatReaderWriter.CellIteration => _bounded.CellIteration;
int IDatReaderWriter.HighResIteration => _bounded.HighResIteration;
int IDatReaderWriter.LanguageIteration => _bounded.LanguageIteration;
[return: MaybeNull]
T IDatObjectSource.Get<T>(uint fileId) =>
_bounded.Get<T>(fileId);
bool IDatObjectSource.TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value)
=> _bounded.TryGet(fileId, out value);
IEnumerable<uint> IDatReaderWriter.GetAllIdsOfType<T>() =>
_bounded.GetAllIdsOfType<T>();
bool IDatReaderWriter.TryGetFileBytes(
uint regionId,
uint fileId,
ref byte[] bytes,
out int bytesRead) =>
_bounded.TryGetFileBytes(regionId, fileId, ref bytes, out bytesRead);
IEnumerable<IDatReaderWriter.IdResolution> IDatReaderWriter.ResolveId(uint id) =>
_bounded.ResolveId(id);
bool IDatReaderWriter.TrySave<T>(T obj, int iteration) =>
_bounded.TrySave(obj, iteration);
bool IDatReaderWriter.TrySave<T>(uint regionId, T obj, int iteration) =>
_bounded.TrySave(regionId, obj, iteration);
}

View file

@ -197,6 +197,45 @@ public sealed class ProjectileControllerTests
Assert.True(entity.Position.X > pendingPosition.X);
}
[Fact]
public void LandblockUnload_SuspendsProjectileAtVisibilityEdgeWithoutFrameScan()
{
var fixture = new Fixture();
LiveEntityRecord record = fixture.Spawn(instance: 1);
WorldEntity entity = record.WorldEntity!;
fixture.Engine.ShadowObjects.Register(
entity.Id,
entity.SourceGfxObjOrSetupId,
entity.Position,
entity.Rotation,
radius: 0.5f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: 0x01010000u,
state: (uint)MissileState,
seedCellId: CellA);
Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0, 1, 1));
Assert.True(record.PhysicsBody!.InWorld);
Assert.True(record.PhysicsBody.IsActive);
Assert.Single(fixture.Live.SpatialProjectileRuntimes);
fixture.Spatial.RemoveLandblock(0x0101FFFFu);
Assert.False(record.IsSpatiallyVisible);
Assert.Empty(fixture.Live.SpatialProjectileRuntimes);
Assert.False(record.PhysicsBody.InWorld);
Assert.False(record.PhysicsBody.IsActive);
Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered);
fixture.Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu));
Assert.Single(fixture.Live.SpatialProjectileRuntimes);
fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null);
Assert.True(record.PhysicsBody.InWorld);
Assert.True(record.PhysicsBody.IsActive);
Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered);
}
[Fact]
public void LoadedLandblockCrossing_RebucketsSameProjection()
{
@ -909,7 +948,7 @@ public sealed class ProjectileControllerTests
body.Position = new Vector3(191f, 10f, 50f);
entity.SetPosition(body.Position);
remote.CellId = startCell;
Assert.True(fixture.Controller.LeaveWorld(Guid));
Assert.True(fixture.Controller.LeaveWorld(record));
fixture.Controller.Tick(1.6, 0xA9, 0xB4, playerWorldPosition: null);
Assert.True(body.InWorld);
Assert.Contains(
@ -1033,7 +1072,7 @@ public sealed class ProjectileControllerTests
new PickupEvent.Parsed(Guid, InstanceSequence: 4, PositionSequence: 2),
out _));
Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid));
Assert.True(fixture.Controller.LeaveWorld(Guid));
Assert.True(fixture.Controller.LeaveWorld(record));
Assert.DoesNotContain(
fixture.Engine.ShadowObjects.GetObjectsInCell(CellA),
entry => entry.EntityId == entity.Id);

View file

@ -2,8 +2,13 @@ using System.Numerics;
using AcDream.App.Physics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Physics;
@ -136,6 +141,45 @@ public sealed class RemotePhysicsUpdaterTests
Assert.False(motion.Airborne);
}
[Fact]
public void TickHiddenEntities_DeletionCallbackCannotAdvanceStaleSnapshotOwner()
{
const uint firstGuid = 0x70000011u;
const uint secondGuid = 0x70000012u;
var spatial = new GpuWorldState();
spatial.AddLandblock(new LoadedLandblock(
0x0101FFFFu,
new LandBlock(),
Array.Empty<WorldEntity>()));
var live = new LiveEntityRuntime(
spatial,
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
BindHiddenRemote(live, firstGuid);
BindHiddenRemote(live, secondGuid);
Assert.Equal(2, live.SpatialRemoteMotionRuntimes.Count);
var updater = new RemotePhysicsUpdater(
new PhysicsEngine(),
(_, _) => (0.48f, 1.835f),
(_, _, _, _) => { });
var published = new List<uint>();
updater.TickHiddenEntities(
live,
localPlayerServerGuid: 0x50000001u,
dt: 0.1f,
entity =>
{
published.Add(entity.ServerGuid);
uint other = entity.ServerGuid == firstGuid ? secondGuid : firstGuid;
Assert.True(live.UnregisterLiveEntity(
new DeleteObject.Parsed(other, InstanceSequence: 1),
isLocalPlayer: false));
});
Assert.Single(published);
Assert.Single(live.SpatialRemoteMotionRuntimes);
}
private static PhysicsEngine BuildBoundaryEngine()
{
static TerrainSurface FlatTerrain()
@ -163,4 +207,77 @@ public sealed class RemotePhysicsUpdaterTests
0f);
return engine;
}
private static void BindHiddenRemote(LiveEntityRuntime live, uint guid)
{
const uint cellId = 0x01010001u;
PhysicsStateFlags state = PhysicsStateFlags.Hidden
| PhysicsStateFlags.IgnoreCollisions;
var position = new CreateObject.ServerPosition(
cellId, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
var physics = new PhysicsSpawnData(
RawState: (uint)state,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
live.RegisterLiveEntity(new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"hidden fixture",
null,
null,
0x09000001u,
PhysicsState: (uint)state,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics));
WorldEntity entity = live.MaterializeLiveEntity(
guid,
cellId,
id => new WorldEntity
{
Id = id,
ServerGuid = guid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = new Vector3(10f, 10f, 5f),
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
ParentCellId = cellId,
})!;
var remote = new GameWindow.RemoteMotion();
remote.Body.Position = entity.Position;
remote.Body.Orientation = entity.Rotation;
remote.CellId = cellId;
remote.Interp.Enqueue(
entity.Position + Vector3.UnitX,
heading: 0f,
isMovingTo: false,
currentBodyPosition: entity.Position);
live.SetRemoteMotionRuntime(guid, remote);
}
}

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));
}
}

View file

@ -0,0 +1,91 @@
using DatReaderWriter;
using System.Reflection;
using System.Text.RegularExpressions;
namespace AcDream.App.Tests;
public sealed class RuntimeDatAccessArchitectureTests
{
[Fact]
public void ProductionAppTypes_DoNotStoreOrAcceptRawDatCollection()
{
Type rawType = typeof(DatReaderWriter.DatCollection);
Type ownerType = typeof(RuntimeDatCollection);
Type[] appTypes = typeof(RuntimeDatCollectionFactory).Assembly.GetTypes();
var violations = new List<string>();
foreach (Type type in appTypes.Where(type => type != ownerType))
{
foreach (FieldInfo field in type.GetFields(
BindingFlags.Instance | BindingFlags.Static |
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (field.FieldType == rawType)
violations.Add($"{type.FullName} field {field.Name}");
}
foreach (PropertyInfo property in type.GetProperties(
BindingFlags.Instance | BindingFlags.Static |
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
{
if (property.PropertyType == rawType)
violations.Add($"{type.FullName} property {property.Name}");
}
foreach (MethodBase method in type.GetMethods(
BindingFlags.Instance | BindingFlags.Static |
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)
.Cast<MethodBase>()
.Concat(type.GetConstructors(
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)))
{
if (method.GetParameters().Any(parameter => parameter.ParameterType == rawType))
violations.Add($"{type.FullName} method {method.Name}");
if (method is MethodInfo info && info.ReturnType == rawType)
violations.Add($"{type.FullName} return {method.Name}");
}
}
Assert.Empty(violations);
}
[Fact]
public void ProductionSource_CreatesRawHandlesAndBoundedAdapterOnlyInRuntimeOwner()
{
string root = FindRepoRoot();
string appRoot = Path.Combine(root, "src", "AcDream.App");
string ownerPath = Path.GetFullPath(
Path.Combine(appRoot, "RuntimeDatCollectionFactory.cs"));
string[] sources = Directory.GetFiles(appRoot, "*.cs", SearchOption.AllDirectories);
string[] rawCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollection\s*\(");
string[] adapterCreates = FindMatchingFiles(sources, @"\bnew\s+DatCollectionAdapter\s*\(");
string[] rawTypedReads = FindMatchingFiles(
sources,
@"\.Db\s*\.\s*(?:Get|TryGet)\s*<");
Assert.Equal([ownerPath], rawCreates);
Assert.Equal([ownerPath], adapterCreates);
Assert.Empty(rawTypedReads);
}
private static string[] FindMatchingFiles(IEnumerable<string> sources, string pattern) =>
sources
.Where(path => Regex.IsMatch(File.ReadAllText(path), pattern))
.Select(Path.GetFullPath)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string FindRepoRoot()
{
string? dir = AppContext.BaseDirectory;
while (dir is not null)
{
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
return dir;
dir = Directory.GetParent(dir)?.FullName;
}
throw new DirectoryNotFoundException("Could not locate AcDream.slnx.");
}
}

View file

@ -0,0 +1,40 @@
using DatReaderWriter.Options;
namespace AcDream.App.Tests;
public sealed class RuntimeDatCollectionFactoryTests
{
[Fact]
public void CreateReadOnlyOptions_BoundsFilePayloadResidencyAndRetainsIndexLookupCache()
{
const string datDirectory = @"C:\RetailDats";
DatCollectionOptions options =
RuntimeDatCollectionFactory.CreateReadOnlyOptions(datDirectory);
Assert.Equal(datDirectory, options.DatDirectory);
Assert.Equal(DatAccessType.Read, options.AccessType);
Assert.Equal(IndexCachingStrategy.OnDemand, options.IndexCachingStrategy);
Assert.Equal(FileCachingStrategy.Never, options.FileCachingStrategy);
Assert.Equal(IndexCachingStrategy.OnDemand, options.PortalIndexCachingStrategy);
Assert.Equal(IndexCachingStrategy.OnDemand, options.CellIndexCachingStrategy);
Assert.Equal(IndexCachingStrategy.OnDemand, options.LocalIndexCachingStrategy);
Assert.Equal(IndexCachingStrategy.OnDemand, options.HighResIndexCachingStrategy);
Assert.Equal(FileCachingStrategy.Never, options.PortalFileCachingStrategy);
Assert.Equal(FileCachingStrategy.Never, options.CellFileCachingStrategy);
Assert.Equal(FileCachingStrategy.Never, options.LocalFileCachingStrategy);
Assert.Equal(FileCachingStrategy.Never, options.HighResFileCachingStrategy);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CreateReadOnlyOptions_RejectsMissingDatDirectory(string? datDirectory)
{
Assert.ThrowsAny<ArgumentException>(() =>
RuntimeDatCollectionFactory.CreateReadOnlyOptions(datDirectory!));
}
}

View file

@ -30,6 +30,7 @@ public sealed class RuntimeOptionsTests
Assert.Null(opts.LiveUser);
Assert.Null(opts.LivePass);
Assert.False(opts.DevTools);
Assert.False(opts.UncappedRendering);
Assert.False(opts.DumpMoveTruth);
Assert.False(opts.NoAudio);
Assert.False(opts.EnableSkyPesDebug);
@ -183,12 +184,14 @@ public sealed class RuntimeOptionsTests
var allOn = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "1",
["ACDREAM_UNCAPPED_RENDER"] = "1",
["ACDREAM_DUMP_MOVE_TRUTH"] = "1",
["ACDREAM_NO_AUDIO"] = "1",
["ACDREAM_ENABLE_SKY_PES"] = "1",
["ACDREAM_DUMP_SCENERY_Z"] = "1",
}));
Assert.True(allOn.DevTools);
Assert.True(allOn.UncappedRendering);
Assert.True(allOn.DumpMoveTruth);
Assert.True(allOn.NoAudio);
Assert.True(allOn.EnableSkyPesDebug);
@ -199,12 +202,14 @@ public sealed class RuntimeOptionsTests
var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new()
{
["ACDREAM_DEVTOOLS"] = "true",
["ACDREAM_UNCAPPED_RENDER"] = "true",
["ACDREAM_DUMP_MOVE_TRUTH"] = "yes",
["ACDREAM_NO_AUDIO"] = "2",
["ACDREAM_ENABLE_SKY_PES"] = "on",
["ACDREAM_DUMP_SCENERY_Z"] = " 1",
}));
Assert.False(anyOther.DevTools);
Assert.False(anyOther.UncappedRendering);
Assert.False(anyOther.DumpMoveTruth);
Assert.False(anyOther.NoAudio);
Assert.False(anyOther.EnableSkyPesDebug);

View file

@ -0,0 +1,77 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class GpuWorldStateAnimatedIndexTests
{
private const uint Landblock = 0x0101FFFFu;
[Fact]
public void StableLandblock_ReusesAnimatedIndexAcrossFrames()
{
var state = new GpuWorldState();
WorldEntity entity = Entity(1u, 0u);
state.AddLandblock(Loaded(entity));
IReadOnlyDictionary<uint, WorldEntity>? first = SingleIndex(state);
IReadOnlyDictionary<uint, WorldEntity>? second = SingleIndex(state);
Assert.Same(first, second);
Assert.Same(entity, first![entity.Id]);
}
[Fact]
public void ReplacedLandblockRecord_RebuildsOnlyThatGeneration()
{
var state = new GpuWorldState();
WorldEntity original = Entity(1u, 0u);
WorldEntity live = Entity(2u, 0x70000002u);
state.AddLandblock(Loaded(original));
IReadOnlyDictionary<uint, WorldEntity>? first = SingleIndex(state);
state.PlaceLiveEntityProjection(Landblock, live);
IReadOnlyDictionary<uint, WorldEntity>? second = SingleIndex(state);
Assert.NotSame(first, second);
Assert.Same(original, second![original.Id]);
Assert.Same(live, second[live.Id]);
Assert.Same(second, SingleIndex(state));
}
[Fact]
public void RemoveThenReload_DoesNotRetainRemovedGeneration()
{
var state = new GpuWorldState();
WorldEntity removed = Entity(1u, 0u);
state.AddLandblock(Loaded(removed));
IReadOnlyDictionary<uint, WorldEntity>? first = SingleIndex(state);
state.RemoveLandblock(Landblock);
WorldEntity replacement = Entity(2u, 0u);
state.AddLandblock(Loaded(replacement));
IReadOnlyDictionary<uint, WorldEntity>? second = SingleIndex(state);
Assert.NotSame(first, second);
Assert.False(second!.ContainsKey(removed.Id));
Assert.Same(replacement, second[replacement.Id]);
}
private static IReadOnlyDictionary<uint, WorldEntity>? SingleIndex(GpuWorldState state) =>
Assert.Single(state.LandblockEntries).AnimatedById;
private static LoadedLandblock Loaded(params WorldEntity[] entities) =>
new(Landblock, new LandBlock(), entities);
private static WorldEntity Entity(uint id, uint serverGuid) => new()
{
Id = id,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
}

View file

@ -11,6 +11,361 @@ namespace AcDream.App.Tests.Streaming;
public sealed class GpuWorldStateVisibilityTests
{
[Fact]
public void PendingSpawnFlood_DoesNotPublishOrRebuildLoadedView()
{
const uint landblock = 0x0101FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
int edges = 0;
state.LiveProjectionVisibilityChanged += (_, _) => edges++;
long commitsBefore = state.VisibilityCommitCount;
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
state.PlaceLiveEntityProjection(
landblock,
Entity((uint)(i + 1), 0x70000000u + (uint)i));
}
Assert.Empty(state.Entities);
Assert.Equal(count, state.PendingLiveEntityCount);
Assert.Equal(commitsBefore, state.VisibilityCommitCount);
Assert.Equal(0, edges);
}
Assert.Empty(state.Entities);
Assert.Equal(count, state.PendingLiveEntityCount);
Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount);
Assert.Equal(0, edges);
}
[Fact]
public void LoadedSpawnFlood_AppendsInPlaceAndPublishesOneBatch()
{
const uint landblock = 0x0101FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
IReadOnlyList<WorldEntity> residentBucket = loaded!.Entities;
int edges = 0;
state.LiveProjectionVisibilityChanged += (_, visible) =>
{
Assert.True(visible);
edges++;
};
long commitsBefore = state.VisibilityCommitCount;
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
state.PlaceLiveEntityProjection(
landblock,
Entity((uint)(i + 1), 0x71000000u + (uint)i));
}
Assert.Same(residentBucket, loaded.Entities);
Assert.Equal(count, residentBucket.Count);
Assert.Equal(count, state.Entities.Count);
Assert.Equal(0, edges);
Assert.Equal(commitsBefore, state.VisibilityCommitCount);
}
Assert.Equal(count, edges);
Assert.Equal(commitsBefore + 1, state.VisibilityCommitCount);
Assert.Equal(count, state.Entities.Count);
}
[Fact]
public void DenseSameBucketRebucketAndDelete_PreserveEveryProjectionIndex()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
const int count = 10_000;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
var entities = new WorldEntity[count];
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
{
entities[i] = Entity((uint)(i + 1), 0x72000000u + (uint)i);
state.PlaceLiveEntityProjection(sourceLandblock, entities[i]);
}
}
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
state.RebucketLiveEntity(entities[i], targetLandblock);
}
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Empty(source!.Entities);
Assert.Equal(count, target!.Entities.Count);
Assert.Equal(count, state.Entities.Count);
using (state.BeginMutationBatch())
{
for (int i = 0; i < count; i++)
state.RemoveLiveEntityProjection(entities[i]);
}
Assert.Empty(target.Entities);
Assert.Empty(state.Entities);
}
[Fact]
public void DenseDemotion_CompactsLiveEntriesAndKeepsTheirIndexesValid()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
const int liveCount = 2_000;
var state = new GpuWorldState();
var mixed = new List<WorldEntity>(liveCount * 2);
var live = new List<WorldEntity>(liveCount);
for (int i = 0; i < liveCount; i++)
{
mixed.Add(Entity(0xC1000000u + (uint)i, 0u));
WorldEntity projection = Entity((uint)(i + 1), 0x73000000u + (uint)i);
mixed.Add(projection);
live.Add(projection);
}
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
mixed));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.RemoveEntitiesFromLandblock(sourceLandblock);
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.Equal(liveCount, source!.Entities.Count);
Assert.All(source.Entities, entity => Assert.NotEqual(0u, entity.ServerGuid));
using (state.BeginMutationBatch())
{
foreach (WorldEntity entity in live)
state.RebucketLiveEntity(entity, targetLandblock);
}
Assert.Empty(source.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Equal(liveCount, target!.Entities.Count);
}
[Fact]
public void PendingDemotion_CompactsMixedStaticAndLiveEntriesAndKeepsLiveIndexesValid()
{
const uint pendingLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
var state = new GpuWorldState();
WorldEntity first = Entity(1u, 0x73500001u);
WorldEntity second = Entity(2u, 0x73500002u);
WorldEntity[] mixed =
[
Entity(0xC1000001u, 0u),
first,
Entity(0xC1000002u, 0u),
second,
];
Assert.False(state.AddEntitiesToExistingLandblock(pendingLandblock, mixed));
Assert.Equal(4, state.PendingLiveEntityCount);
state.RemoveEntitiesFromLandblock(pendingLandblock);
Assert.Equal(2, state.PendingLiveEntityCount);
state.AddLandblock(new LoadedLandblock(
pendingLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.True(state.TryGetLandblock(pendingLandblock, out LoadedLandblock? pending));
Assert.Equal([first, second], pending!.Entities);
state.RebucketLiveEntity(first, targetLandblock);
state.RebucketLiveEntity(second, targetLandblock);
Assert.Empty(pending.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Equal(2, target!.Entities.Count);
Assert.Contains(first, target.Entities);
Assert.Contains(second, target.Entities);
}
[Fact]
public void UnloadToPendingThenReload_PreservesProjectionIdentityAndVisibilityEdges()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73600001u);
state.PlaceLiveEntityProjection(landblock, entity);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible));
state.RemoveLandblock(landblock);
Assert.Empty(state.Entities);
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.False(state.IsLiveEntityVisible(entity.ServerGuid));
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? reloaded));
Assert.Same(entity, Assert.Single(reloaded!.Entities));
Assert.True(state.IsLiveEntityVisible(entity.ServerGuid));
Assert.Equal(
[(entity.ServerGuid, false), (entity.ServerGuid, true)],
edges);
state.RemoveLiveEntityProjection(entity);
Assert.Empty(state.Entities);
Assert.Empty(reloaded.Entities);
}
[Fact]
public void BatchedVisibilityObserver_SeesCommittedFlatAndResidentViews()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73700001u);
int callbacks = 0;
state.LiveProjectionVisibilityChanged += (guid, visible) =>
{
callbacks++;
Assert.Equal(entity.ServerGuid, guid);
Assert.True(visible);
Assert.True(state.IsLiveEntityVisible(guid));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
Assert.Same(entity, Assert.Single(loaded!.Entities));
};
using (state.BeginMutationBatch())
{
state.PlaceLiveEntityProjection(landblock, entity);
Assert.Equal(0, callbacks);
}
Assert.Equal(1, callbacks);
}
[Fact]
public void LoadedToLoadedRebucket_EmitsNoVisibilityPulse()
{
const uint sourceLandblock = 0x0101FFFFu;
const uint targetLandblock = 0x0202FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
sourceLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
state.AddLandblock(new LoadedLandblock(
targetLandblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x73800001u);
state.PlaceLiveEntityProjection(sourceLandblock, entity);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (guid, visible) => edges.Add((guid, visible));
state.RebucketLiveEntity(entity, targetLandblock);
Assert.Empty(edges);
Assert.True(state.IsLiveEntityVisible(entity.ServerGuid));
Assert.True(state.TryGetLandblock(sourceLandblock, out LoadedLandblock? source));
Assert.Empty(source!.Entities);
Assert.True(state.TryGetLandblock(targetLandblock, out LoadedLandblock? target));
Assert.Same(entity, Assert.Single(target!.Entities));
Assert.Same(entity, Assert.Single(state.Entities));
}
[Fact]
public void SameGuidOverlap_ExactRemovalPromotesSecondaryWithoutVisibilityPulse()
{
const uint landblock = 0x0101FFFFu;
const uint guid = 0x73900001u;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity first = Entity(1u, guid);
WorldEntity second = Entity(2u, guid);
state.PlaceLiveEntityProjection(landblock, first);
state.PlaceLiveEntityProjection(landblock, second);
var edges = new List<(uint Guid, bool Visible)>();
state.LiveProjectionVisibilityChanged += (edgeGuid, visible) =>
edges.Add((edgeGuid, visible));
state.RemoveLiveEntityProjection(first);
Assert.Empty(edges);
Assert.True(state.IsLiveEntityVisible(guid));
Assert.Same(second, Assert.Single(state.Entities));
state.RemoveLiveEntityProjection(guid);
Assert.Equal([(guid, false)], edges);
Assert.False(state.IsLiveEntityVisible(guid));
Assert.Empty(state.Entities);
}
[Fact]
public void DuplicateDirectPlacement_IsRejectedBeforeAnyBucketMutation()
{
const uint landblock = 0x0101FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblock,
new LandBlock(),
Array.Empty<WorldEntity>()));
WorldEntity entity = Entity(1u, 0x74000001u);
state.PlaceLiveEntityProjection(landblock, entity);
Assert.Throws<InvalidOperationException>(() =>
state.PlaceLiveEntityProjection(landblock, entity));
Assert.Same(entity, Assert.Single(state.Entities));
Assert.True(state.TryGetLandblock(landblock, out LoadedLandblock? loaded));
Assert.Same(entity, Assert.Single(loaded!.Entities));
}
[Fact]
public void ThrowingObserver_DoesNotStrandLaterSubscribersOrQueuedOwnerEdges()
{
@ -80,6 +435,42 @@ public sealed class GpuWorldStateVisibilityTests
Assert.Equal(0, state.PendingVisibilityTransitionCount);
}
[Fact]
public void CopyLiveEntitiesNearLandblock_UsesBoundedNeighborhoodAndSkipsStatics()
{
var centerLive = Entity(1, 0x70000001u);
var neighborLive = Entity(2, 0x70000002u);
var farLive = Entity(3, 0x70000003u);
WorldEntity[] staticEntities = Enumerable.Range(0, 20_000)
.Select(index => Entity((uint)(index + 10), 0u))
.ToArray();
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
0x1010FFFFu,
new LandBlock(),
staticEntities.Append(centerLive).ToArray()));
state.AddLandblock(new LoadedLandblock(
0x1110FFFFu,
new LandBlock(),
new[] { neighborLive }));
state.AddLandblock(new LoadedLandblock(
0x1310FFFFu,
new LandBlock(),
new[] { farLive }));
var destination = new List<KeyValuePair<uint, WorldEntity>>
{
new(0xDEADBEEFu, farLive),
};
state.CopyLiveEntitiesNearLandblock(0x10100001u, 1, destination);
Assert.Equal(2, destination.Count);
Assert.Contains(destination, pair => pair.Key == centerLive.ServerGuid);
Assert.Contains(destination, pair => pair.Key == neighborLive.ServerGuid);
Assert.DoesNotContain(destination, pair => pair.Key == farLive.ServerGuid);
Assert.DoesNotContain(destination, pair => pair.Key == 0u);
}
private static WorldEntity Entity(uint id, uint guid) => new()
{
Id = id,

View file

@ -0,0 +1,202 @@
using System.Numerics;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.App.Tests.Streaming;
public sealed class LandblockRetirementCoordinatorTests
{
[Fact]
public void EntityStageFailure_DetachesImmediately_AndResumesAtFailedEntity()
{
const uint landblockId = 0x2020FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
new[] { Entity(1), Entity(2), Entity(3) }));
var calls = new Dictionary<uint, int>();
bool failEntityTwo = true;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
lighting: entity =>
{
calls[entity.Id] = calls.GetValueOrDefault(entity.Id) + 1;
if (entity.Id == 2 && failEntityTwo)
{
failEntityTwo = false;
throw new InvalidOperationException("injected owner failure");
}
}));
coordinator.BeginFull(landblockId);
Assert.False(state.IsLoaded(landblockId));
Assert.Equal(1, coordinator.PendingCount);
Assert.Equal(1, calls[1]);
Assert.Equal(1, calls[2]);
Assert.False(calls.ContainsKey(3));
coordinator.Advance();
Assert.Equal(0, coordinator.PendingCount);
Assert.Equal(1, calls[1]);
Assert.Equal(2, calls[2]);
Assert.Equal(1, calls[3]);
}
[Fact]
public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending()
{
uint[] ids = [0x2020FFFFu, 0x2121FFFFu, 0x2222FFFFu];
var state = new GpuWorldState();
foreach (uint id in ids)
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
bool failOnce = true;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
if (ticket.LandblockId == ids[1] && failOnce)
{
failOnce = false;
throw new InvalidOperationException("injected terrain failure");
}
}));
var controller = Controller(state, coordinator);
controller.ForceReloadWindow();
foreach (uint id in ids)
Assert.False(state.IsLoaded(id));
Assert.Equal(1, coordinator.PendingCount);
coordinator.Advance();
Assert.Equal(0, coordinator.PendingCount);
}
[Fact]
public void ReplacementPublication_WaitsForSameIdRetirementFence()
{
const uint landblockId = 0x3232FFFFu;
var state = new GpuWorldState();
state.AddLandblock(new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
int terrainAttempts = 0;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
terrainAttempts++;
if (terrainAttempts <= 2)
throw new InvalidOperationException("injected terrain failure");
}));
var pending = new Queue<LandblockStreamResult>();
int applyCount = 0;
var controller = Controller(
state,
coordinator,
drain: max => Drain(pending, max),
apply: (_, _) => applyCount++);
controller.ForceReloadWindow(); // generation 1, first retirement attempt
var replacement = new LoadedLandblock(
landblockId,
new LandBlock(),
Array.Empty<WorldEntity>());
pending.Enqueue(new LandblockStreamResult.Loaded(
landblockId,
LandblockStreamTier.Near,
replacement,
EmptyMesh(),
generation: 1));
controller.Tick(0x32, 0x32); // second failure; publication stays deferred
Assert.Equal(0, applyCount);
Assert.False(state.IsLoaded(landblockId));
Assert.True(coordinator.IsPending(landblockId));
controller.Tick(0x32, 0x32); // third attempt completes, then publishes once
Assert.Equal(1, applyCount);
Assert.True(state.IsLoaded(landblockId));
Assert.False(coordinator.IsPending(landblockId));
}
private static StreamingController Controller(
GpuWorldState state,
LandblockRetirementCoordinator coordinator,
Func<int, IReadOnlyList<LandblockStreamResult>>? drain = null,
Action<LandblockBuild, LandblockMeshData>? apply = null) =>
new(
enqueueLoad: (_, _, _) => { },
enqueueUnload: (_, _) => { },
drainCompletions: drain ?? (_ => Array.Empty<LandblockStreamResult>()),
applyTerrain: apply ?? ((_, _) => { }),
state: state,
nearRadius: 0,
farRadius: 0,
retirementCoordinator: coordinator);
private static IReadOnlyList<LandblockStreamResult> Drain(
Queue<LandblockStreamResult> pending,
int max)
{
var result = new List<LandblockStreamResult>(max);
while (result.Count < max && pending.TryDequeue(out LandblockStreamResult? item))
result.Add(item);
return result;
}
private static void CompletePresentation(
LandblockRetirementTicket ticket,
Action<WorldEntity>? lighting = null,
Action? terrain = null)
{
ticket.RunForEachEntity(
LandblockRetirementStage.EntityLighting,
static _ => true,
lighting ?? (_ => { }));
ticket.RunForEachEntity(
LandblockRetirementStage.EntityTranslucency,
static _ => true,
static _ => { });
ticket.RunForEachEntity(
LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
static _ => { });
if (ticket.Kind == LandblockRetirementKind.Full)
ticket.RunOnce(LandblockRetirementStage.Terrain, terrain ?? (() => { }));
ticket.RunOnce(LandblockRetirementStage.Physics, static () => { });
ticket.RunOnce(LandblockRetirementStage.CellVisibility, static () => { });
ticket.RunOnce(LandblockRetirementStage.BuildingRegistry, static () => { });
ticket.RunOnce(LandblockRetirementStage.EnvironmentCells, static () => { });
}
private static WorldEntity Entity(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000000u + id,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private static LandblockMeshData EmptyMesh() => new(
Array.Empty<TerrainVertex>(),
Array.Empty<uint>());
}

View file

@ -327,7 +327,7 @@ public sealed class StreamingControllerReadinessTests
var outbox = new Queue<LandblockStreamResult>();
int ensureCalls = 0;
var appliedBuilds = new List<LandblockBuild>();
var demotedWhileStaticPresent = new List<uint>();
var demotedAfterStaticDetach = new List<uint>();
var shell = new EnvCellShellPlacement(
0x12360001u,
geometryId,
@ -374,8 +374,8 @@ public sealed class StreamingControllerReadinessTests
farRadius: 3,
demoteNearLayer: demotedId =>
{
Assert.Contains(staticEntity, state.Entities);
demotedWhileStaticPresent.Add(demotedId);
Assert.DoesNotContain(staticEntity, state.Entities);
demotedAfterStaticDetach.Add(demotedId);
},
ensureEnvCellMeshes: _ => ensureCalls++);
@ -396,7 +396,7 @@ public sealed class StreamingControllerReadinessTests
Assert.Equal(1, ensureCalls);
Assert.DoesNotContain(geometryId, meshes.ReferenceCounts);
Assert.DoesNotContain(staticEntity, state.Entities);
Assert.Equal(new[] { id }, demotedWhileStaticPresent);
Assert.Equal(new[] { id }, demotedAfterStaticDetach);
Assert.True(state.IsLoaded(id));
Assert.False(state.IsNearTier(id));
Assert.Equal(2, appliedBuilds.Count);

View file

@ -179,6 +179,36 @@ public class ChatWindowControllerTests
Assert.Contains(ctrl!.Input, bar!.Children);
}
[Fact]
public void TranscriptLayout_IsReusedUntilContentOrWidthChanges()
{
var (rootInfo, layout, vm) = BuildTestTree();
var bus = new CaptureBus();
var ctrl = ChatWindowController.Bind(
rootInfo, layout, vm, () => bus, null, null, NoTex)!;
vm.ShowSystemMessage("one wrapped transcript line");
IReadOnlyList<UiText.Line> first = ctrl.Transcript.LinesProvider();
IReadOnlyList<UiText.Line> unchanged = ctrl.Transcript.LinesProvider();
Assert.Same(first, unchanged);
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
vm.ShowSystemMessage("second line");
IReadOnlyList<UiText.Line> appended = ctrl.Transcript.LinesProvider();
Assert.NotSame(first, appended);
Assert.Equal(2, ctrl.TranscriptLayoutBuildCount);
ctrl.Transcript.Width -= 40f;
IReadOnlyList<UiText.Line> resized = ctrl.Transcript.LinesProvider();
Assert.NotSame(appended, resized);
Assert.Equal(3, ctrl.TranscriptLayoutBuildCount);
vm.Clear();
Assert.Empty(ctrl.Transcript.LinesProvider());
Assert.Equal(4, ctrl.TranscriptLayoutBuildCount);
}
// ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ─────
[Fact]

View file

@ -187,6 +187,55 @@ public sealed class RadarSnapshotProviderTests
Assert.Equal(monster, Assert.Single(snapshot.Blips).ObjectId);
}
[Fact]
public void BuildSnapshot_UsesBoundedSpatialCandidatesBeforeRetailProjection()
{
const uint player = 40u;
const uint nearby = 41u;
const uint unrelated = 42u;
var objects = new ClientObjectTable();
objects.Ingest(Weenie(player, "Player", ItemType.Creature));
objects.Ingest(Weenie(nearby, "Nearby", ItemType.Creature) with
{
RadarBehavior = (byte)RadarBehavior.ShowAlways,
});
objects.Ingest(Weenie(unrelated, "Unrelated", ItemType.Creature) with
{
RadarBehavior = (byte)RadarBehavior.ShowAlways,
});
var entities = new Dictionary<uint, WorldEntity>
{
[player] = Entity(player, Vector3.Zero, Quaternion.Identity),
[nearby] = Entity(nearby, new Vector3(5f, 0f, 0f), Quaternion.Identity),
[unrelated] = Entity(unrelated, new Vector3(6f, 0f, 0f), Quaternion.Identity),
};
var spawns = entities.Keys.ToDictionary(guid => guid, Spawn);
uint copiedCell = 0;
int copiedRadius = -1;
var provider = new RadarSnapshotProvider(
objects,
() => entities,
() => spawns,
playerGuid: () => player,
playerYawRadians: () => 0f,
playerCellId: () => 0xA9B40001u,
selectedGuid: () => null,
coordinatesOnRadar: () => true,
uiLocked: () => false,
copySpatialCandidates: (cell, radius, destination) =>
{
copiedCell = cell;
copiedRadius = radius;
destination.Add(new KeyValuePair<uint, WorldEntity>(nearby, entities[nearby]));
});
UiRadarSnapshot snapshot = provider.BuildSnapshot();
Assert.Equal(0xA9B40001u, copiedCell);
Assert.Equal(1, copiedRadius);
Assert.Equal(nearby, Assert.Single(snapshot.Blips).ObjectId);
}
private static WorldEntity Entity(uint guid, Vector3 position, Quaternion rotation) => new()
{
Id = guid,

View file

@ -0,0 +1,118 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiCompositeShutdownTests
{
[Fact]
public void UiHostShutdownRetainsFailedInputAndProtectsDependentOwners()
{
var calls = new List<string>();
bool failInput = true;
ResourceShutdownTransaction transaction = UiHost.CreateShutdownTransaction(
[
() => calls.Add("last-input"),
() =>
{
calls.Add("failed-input");
if (failInput)
throw new InvalidOperationException("synthetic unsubscribe failure");
},
],
() => calls.Add("input-state"),
() => calls.Add("windows"),
() => calls.Add("text"));
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
Assert.Equal(1, calls.Count(call => call == "last-input"));
Assert.Equal(3, calls.Count(call => call == "failed-input"));
Assert.DoesNotContain("input-state", calls);
Assert.DoesNotContain("windows", calls);
Assert.DoesNotContain("text", calls);
failInput = false;
transaction.CompleteOrThrow();
Assert.True(transaction.IsComplete);
Assert.Equal(1, calls.Count(call => call == "last-input"));
Assert.Equal(4, calls.Count(call => call == "failed-input"));
Assert.Equal(1, calls.Count(call => call == "input-state"));
Assert.Equal(1, calls.Count(call => call == "windows"));
Assert.Equal(1, calls.Count(call => call == "text"));
}
[Fact]
public void UiHostShutdownRetriesRendererWithoutReplayingWindowManager()
{
int windowCalls = 0;
int rendererCalls = 0;
bool failRenderer = true;
ResourceShutdownTransaction transaction = UiHost.CreateShutdownTransaction(
[],
() => { },
() => windowCalls++,
() =>
{
rendererCalls++;
if (failRenderer)
throw new InvalidOperationException("synthetic renderer failure");
});
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
Assert.Equal(1, windowCalls);
Assert.Equal(2, rendererCalls);
failRenderer = false;
transaction.CompleteOrThrow();
Assert.True(transaction.IsComplete);
Assert.Equal(1, windowCalls);
Assert.Equal(3, rendererCalls);
}
[Fact]
public void RetailRuntimeShutdownReentersSafelyAndGatesHostBehindControllers()
{
var calls = new List<string>();
bool failGameplay = true;
ResourceShutdownTransaction? transaction = null;
transaction = RetailUiRuntime.CreateShutdownTransaction(
() => calls.Add("persistence"),
() => calls.Add("visibility"),
() =>
{
calls.Add("item");
transaction!.CompleteOrThrow();
},
() =>
{
calls.Add("gameplay");
if (failGameplay)
throw new InvalidOperationException("synthetic controller failure");
},
() => calls.Add("dialogs"),
() => calls.Add("panels"),
() => calls.Add("host"));
Assert.Throws<AggregateException>(transaction.CompleteOrThrow);
Assert.Equal(1, calls.Count(call => call == "persistence"));
Assert.Equal(1, calls.Count(call => call == "visibility"));
Assert.Equal(1, calls.Count(call => call == "item"));
Assert.Equal(3, calls.Count(call => call == "gameplay"));
Assert.DoesNotContain("dialogs", calls);
Assert.DoesNotContain("panels", calls);
Assert.DoesNotContain("host", calls);
failGameplay = false;
transaction.CompleteOrThrow();
Assert.True(transaction.IsComplete);
Assert.Equal(1, calls.Count(call => call == "item"));
Assert.Equal(1, calls.Count(call => call == "dialogs"));
Assert.Equal(1, calls.Count(call => call == "panels"));
Assert.Equal(1, calls.Count(call => call == "host"));
}
}

View file

@ -0,0 +1,67 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiElementChildOrderTests
{
[Fact]
public void StableTree_ReusesBothTraversalSnapshots()
{
var parent = new TestElement();
parent.AddChild(new TestElement { ZOrder = 2 });
parent.AddChild(new TestElement { ZOrder = 1 });
UiElement[] drawFirst = parent.ChildrenBackToFrontSnapshot();
UiElement[] drawSecond = parent.ChildrenBackToFrontSnapshot();
UiElement[] hitFirst = parent.ChildrenFrontToBackSnapshot();
UiElement[] hitSecond = parent.ChildrenFrontToBackSnapshot();
Assert.Same(drawFirst, drawSecond);
Assert.Same(hitFirst, hitSecond);
Assert.Equal([1, 2], drawFirst.Select(child => child.ZOrder).ToArray());
Assert.Equal([2, 1], hitFirst.Select(child => child.ZOrder).ToArray());
}
[Fact]
public void ChildZOrderChange_InvalidatesBothTraversalSnapshots()
{
var parent = new TestElement();
var first = new TestElement { ZOrder = 1 };
var second = new TestElement { ZOrder = 2 };
parent.AddChild(first);
parent.AddChild(second);
UiElement[] oldDraw = parent.ChildrenBackToFrontSnapshot();
UiElement[] oldHit = parent.ChildrenFrontToBackSnapshot();
first.ZOrder = 3;
UiElement[] newDraw = parent.ChildrenBackToFrontSnapshot();
UiElement[] newHit = parent.ChildrenFrontToBackSnapshot();
Assert.NotSame(oldDraw, newDraw);
Assert.NotSame(oldHit, newHit);
Assert.Same(second, newDraw[0]);
Assert.Same(first, newDraw[1]);
Assert.Same(first, newHit[0]);
Assert.Same(second, newHit[1]);
}
[Fact]
public void MembershipChange_InvalidatesSnapshotsWithoutMutatingPriorWalk()
{
var parent = new TestElement();
var first = new TestElement { ZOrder = 1 };
var second = new TestElement { ZOrder = 2 };
parent.AddChild(first);
UiElement[] prior = parent.ChildrenBackToFrontSnapshot();
parent.AddChild(second);
UiElement[] current = parent.ChildrenBackToFrontSnapshot();
Assert.Single(prior);
Assert.Same(first, prior[0]);
Assert.Equal(2, current.Length);
Assert.NotSame(prior, current);
}
private sealed class TestElement : UiElement;
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,125 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Lib.IO;
using System.Collections.Concurrent;
namespace AcDream.Content.Tests;
public sealed class BoundedDatObjectCacheTests
{
[Fact]
public void GetOrAdd_EvictsLeastRecentlyUsedEntryAtEntryLimit()
{
var cache = CreateCache(entryLimit: 2, byteLimit: 100);
var first = Surface(1);
var second = Surface(2);
var third = Surface(3);
cache.GetOrAdd(1, first);
cache.GetOrAdd(2, second);
Assert.True(cache.TryGet<Surface>(1, out _)); // first is now hottest
cache.GetOrAdd(3, third);
Assert.True(cache.TryGet<Surface>(1, out var retainedFirst));
Assert.Same(first, retainedFirst);
Assert.False(cache.TryGet<Surface>(2, out _));
Assert.True(cache.TryGet<Surface>(3, out var retainedThird));
Assert.Same(third, retainedThird);
Assert.Equal(2, cache.Count);
}
[Fact]
public void GetOrAdd_EnforcesEstimatedByteBudgetAndDoesNotRetainOversizeEntry()
{
var cache = new BoundedDatObjectCache(
entryLimit: 10,
estimatedByteLimit: 10,
estimateRetainedBytes: value => value.Id);
cache.GetOrAdd(6, Surface(6));
cache.GetOrAdd(5, Surface(5));
Assert.False(cache.TryGet<Surface>(6, out _));
Assert.True(cache.TryGet<Surface>(5, out _));
Assert.Equal(5, cache.EstimatedBytes);
var oversize = Surface(11);
Assert.Same(oversize, cache.GetOrAdd(11, oversize));
Assert.False(cache.TryGet<Surface>(11, out _));
Assert.Equal(1, cache.Count);
Assert.Equal(5, cache.EstimatedBytes);
}
[Fact]
public void ProductionEstimator_ChargesKnownRenderSurfacePayload()
{
const int byteLimit = 512 * 1024;
var cache = new BoundedDatObjectCache(
entryLimit: 10,
estimatedByteLimit: byteLimit);
var oversizeTexture = new RenderSurface
{
Id = 12,
SourceData = new byte[byteLimit + 1],
};
Assert.Same(oversizeTexture, cache.GetOrAdd(12, oversizeTexture));
Assert.False(cache.TryGet<RenderSurface>(12, out _));
Assert.Equal(0, cache.Count);
Assert.Equal(0, cache.EstimatedBytes);
}
[Fact]
public void GetOrAdd_DuplicateKeyKeepsOneCanonicalObject()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var canonical = Surface(7);
var duplicate = Surface(7);
Assert.Same(canonical, cache.GetOrAdd(7, canonical));
Assert.Same(canonical, cache.GetOrAdd(7, duplicate));
Assert.Equal(1, cache.Count);
Assert.Equal(1, cache.EstimatedBytes);
}
[Fact]
public void GetOrAdd_ConcurrentDuplicateKeyPublishesOneCanonicalObject()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var returned = new ConcurrentBag<Surface>();
Parallel.For(0, 1_000, i =>
{
returned.Add(cache.GetOrAdd(42, Surface((uint)(1_000 + i))));
});
Surface[] results = returned.ToArray();
Assert.NotEmpty(results);
Surface canonical = results[0];
Assert.All(results, value => Assert.Same(canonical, value));
Assert.True(cache.TryGet<Surface>(42, out var retained));
Assert.Same(canonical, retained);
Assert.Equal(1, cache.Count);
}
[Fact]
public void SameFileIdWithDifferentRequestedTypes_DoesNotAlias()
{
var cache = CreateCache(entryLimit: 4, byteLimit: 100);
var surface = Surface(99);
var palette = new Palette { Id = 99 };
cache.GetOrAdd<Surface>(99, surface);
cache.GetOrAdd<Palette>(99, palette);
Assert.True(cache.TryGet<Surface>(99, out var retainedSurface));
Assert.True(cache.TryGet<Palette>(99, out var retainedPalette));
Assert.Same(surface, retainedSurface);
Assert.Same(palette, retainedPalette);
Assert.Equal(2, cache.Count);
}
private static BoundedDatObjectCache CreateCache(int entryLimit, long byteLimit) =>
new(entryLimit, byteLimit, _ => 1);
private static Surface Surface(uint id) => new() { Id = id };
}

View file

@ -0,0 +1,152 @@
using AcDream.Content;
using Xunit;
namespace AcDream.Content.Tests;
public sealed class DecodedTextureCacheTests {
private static DecodedTextureKey Key(
uint id,
bool clip = false,
bool additive = false) => new(id, clip, additive);
[Fact]
public void RetainOrUse_EvictsLeastRecentlyUsedEntryToMeetByteBudget() {
var cache = new DecodedTextureCache(maximumBytes: 8, maximumEntries: 8);
var first = new byte[4];
var second = new byte[4];
var third = new byte[4];
Assert.Same(first, cache.RetainOrUse(Key(1), first, out var firstCached));
Assert.Same(second, cache.RetainOrUse(Key(2), second, out var secondCached));
Assert.True(cache.TryGet(Key(1), out _)); // ID 2 is now least recently used.
Assert.Same(third, cache.RetainOrUse(Key(3), third, out var thirdCached));
Assert.True(firstCached);
Assert.True(secondCached);
Assert.True(thirdCached);
Assert.True(cache.TryGet(Key(1), out _));
Assert.False(cache.TryGet(Key(2), out _));
Assert.True(cache.TryGet(Key(3), out _));
Assert.Equal(8, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_DoesNotAdmitOversizedEntry() {
var cache = new DecodedTextureCache(maximumBytes: 3, maximumEntries: 8);
var pixels = new byte[4];
Assert.Same(pixels, cache.RetainOrUse(Key(1), pixels, out var isCached));
Assert.False(isCached);
Assert.False(cache.TryGet(Key(1), out _));
Assert.Equal(0, cache.Count);
Assert.Equal(0, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_ExistingCanonicalEntryWinsConcurrentDecodeRace() {
var cache = new DecodedTextureCache(maximumBytes: 16, maximumEntries: 8);
var canonical = new byte[4];
var duplicate = new byte[4];
cache.RetainOrUse(Key(1), canonical, out _);
var result = cache.RetainOrUse(Key(1), duplicate, out var isCached);
Assert.True(isCached);
Assert.Same(canonical, result);
Assert.Equal(1, cache.Count);
Assert.Equal(4, cache.ResidentBytes);
}
[Fact]
public void RetainOrUse_EnforcesEntryBudget() {
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 2);
cache.RetainOrUse(Key(1), new byte[1], out _);
cache.RetainOrUse(Key(2), new byte[1], out _);
cache.RetainOrUse(Key(3), new byte[1], out _);
Assert.False(cache.TryGet(Key(1), out _));
Assert.True(cache.TryGet(Key(2), out _));
Assert.True(cache.TryGet(Key(3), out _));
Assert.Equal(2, cache.Count);
}
[Fact]
public void DecodeAffectingSurfaceFlags_DoNotAliasSameRenderSurface()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
var opaque = new byte[] { 1 };
var clip = new byte[] { 2 };
var additive = new byte[] { 3 };
cache.RetainOrUse(Key(1), opaque, out _);
cache.RetainOrUse(Key(1, clip: true), clip, out _);
cache.RetainOrUse(Key(1, additive: true), additive, out _);
Assert.Same(opaque, AssertCacheHit(cache, Key(1)));
Assert.Same(clip, AssertCacheHit(cache, Key(1, clip: true)));
Assert.Same(additive, AssertCacheHit(cache, Key(1, additive: true)));
}
[Fact]
public async Task GetOrCreate_ConcurrentMissRunsFactoryOnce()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
int calls = 0;
using var release = new ManualResetEventSlim();
Task<byte[]>[] readers = Enumerable.Range(0, 8)
.Select(readerIndex => Task.Run(() => cache.GetOrCreate(
Key(1),
() =>
{
Interlocked.Increment(ref calls);
release.Wait();
return new byte[] { 1, 2, 3, 4 };
},
out _)))
.ToArray();
Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref calls) == 1, 5_000));
release.Set();
byte[][] results = await Task.WhenAll(readers);
Assert.Equal(1, calls);
Assert.All(results, result => Assert.Same(results[0], result));
}
[Fact]
public void GetOrCreate_FailedFactoryCanRetry()
{
var cache = new DecodedTextureCache(maximumBytes: 1024, maximumEntries: 8);
int calls = 0;
Assert.Throws<InvalidOperationException>(() => cache.GetOrCreate(
Key(1),
() =>
{
calls++;
throw new InvalidOperationException("decode failed");
},
out _));
byte[] recovered = cache.GetOrCreate(
Key(1),
() =>
{
calls++;
return new byte[] { 1 };
},
out var retained);
Assert.Equal(2, calls);
Assert.True(retained);
Assert.Equal(new byte[] { 1 }, recovered);
}
private static byte[] AssertCacheHit(DecodedTextureCache cache, DecodedTextureKey key)
{
Assert.True(cache.TryGet(key, out byte[] pixels));
return pixels;
}
}

View file

@ -292,7 +292,8 @@ public sealed class RetailDatLoaderTests
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var loader = new RetailPhysicsScriptLoader(dats);
using var boundedDats = new DatCollectionAdapter(dats);
var loader = new RetailPhysicsScriptLoader(boundedDats);
foreach (uint id in new[] { 0x33000AEAu, 0x33000AF8u })
{

View file

@ -1,4 +1,5 @@
using AcDream.Content.Vfx;
using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
@ -48,12 +49,13 @@ public sealed class HumanoidMotionTableRootMotionTests
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var boundedDats = new DatCollectionAdapter(dats);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(HumanoidSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(HumanoidMotionTable));
var sequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
new RetailAnimationLoader(boundedDats));
sequencer.SetCycle(NonCombatStyle, Ready);
sequencer.SetCycle(NonCombatStyle, InterpretedWalkForward);

View file

@ -84,4 +84,121 @@ public class WorldEventsTests
Assert.Contains(1u, seen);
Assert.Contains(3u, seen);
}
[Fact]
public void RehydratedEntity_ReplacesReplaySnapshotInsteadOfAppendingHistory()
{
var events = new WorldEvents();
events.FireEntitySpawned(S(1));
events.FireEntitySpawned(S(1) with { SourceId = 0x01000001u });
var seen = new List<WorldEntitySnapshot>();
events.EntitySpawned += seen.Add;
WorldEntitySnapshot replay = Assert.Single(seen);
Assert.Equal(0x01000001u, replay.SourceId);
}
[Fact]
public void ForgottenEntity_IsNotReplayedToLateSubscriber()
{
var events = new WorldEvents();
events.FireEntitySpawned(S(1));
events.FireEntitySpawned(S(2));
Assert.True(events.ForgetEntity(1));
var seen = new List<uint>();
events.EntitySpawned += e => seen.Add(e.Id);
Assert.Equal(new uint[] { 2 }, seen);
}
[Fact]
public void ClearCurrent_LeavesNoReplayHistory()
{
var events = new WorldEvents();
events.FireEntitySpawned(S(1));
events.FireEntitySpawned(S(2));
events.ClearCurrent();
var seen = new List<uint>();
events.EntitySpawned += e => seen.Add(e.Id);
Assert.Empty(seen);
}
[Fact]
public async Task LiveEventDuringReplay_IsDeliveredAfterSnapshotWithoutStaleReordering()
{
var events = new WorldEvents();
events.FireEntitySpawned(S(1));
using var replayEntered = new ManualResetEventSlim();
using var releaseReplay = new ManualResetEventSlim();
var seen = new List<uint>();
Action<WorldEntitySnapshot> handler = snapshot =>
{
lock (seen)
seen.Add(snapshot.SourceId);
if (snapshot.SourceId == 0x01000000u)
{
replayEntered.Set();
releaseReplay.Wait();
}
};
Task subscribe = Task.Run(() => events.EntitySpawned += handler);
Assert.True(replayEntered.Wait(5_000));
events.FireEntitySpawned(S(1) with { SourceId = 0x01000001u });
releaseReplay.Set();
await subscribe;
lock (seen)
Assert.Equal(new uint[] { 0x01000000u, 0x01000001u }, seen);
}
[Fact]
public async Task ClearCurrent_DropsLiveEventsQueuedBehindAnActiveReplay()
{
var events = new WorldEvents();
events.FireEntitySpawned(S(1));
using var replayEntered = new ManualResetEventSlim();
using var releaseReplay = new ManualResetEventSlim();
var seen = new List<uint>();
Action<WorldEntitySnapshot> handler = snapshot =>
{
lock (seen)
seen.Add(snapshot.Id);
if (snapshot.Id == 1)
{
replayEntered.Set();
releaseReplay.Wait();
}
};
Task subscribe = Task.Run(() => events.EntitySpawned += handler);
Assert.True(replayEntered.Wait(5_000));
events.FireEntitySpawned(S(2));
events.ClearCurrent();
releaseReplay.Set();
await subscribe;
lock (seen)
Assert.Equal(new uint[] { 1 }, seen);
}
[Fact]
public void UpsertCurrent_RestoresLateReplayWithoutNotifyingExistingSubscriber()
{
var events = new WorldEvents();
var existing = new List<uint>();
events.EntitySpawned += snapshot => existing.Add(snapshot.Id);
events.FireEntitySpawned(S(1));
events.ForgetEntity(1);
events.UpsertCurrent(S(1) with { SourceId = 0x01000001u });
Assert.Equal(new uint[] { 1 }, existing);
var late = new List<WorldEntitySnapshot>();
events.EntitySpawned += late.Add;
Assert.Equal(0x01000001u, Assert.Single(late).SourceId);
}
}

View file

@ -0,0 +1,50 @@
using System.Numerics;
using AcDream.Core.Plugins;
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Plugins;
public sealed class WorldGameStateTests
{
private static WorldEntitySnapshot S(uint id, uint sourceId = 0x01000000u) =>
new(id, sourceId, Vector3.Zero, Quaternion.Identity);
[Fact]
public void Add_ReplacesExistingProjectionById()
{
var state = new WorldGameState();
state.Add(S(1));
state.Add(S(1, 0x01000001u));
WorldEntitySnapshot current = Assert.Single(state.Entities);
Assert.Equal(0x01000001u, current.SourceId);
}
[Fact]
public void RemoveById_CompactsIndexWithoutLosingOtherEntities()
{
var state = new WorldGameState();
state.Add(S(1));
state.Add(S(2));
state.Add(S(3));
Assert.True(state.RemoveById(2));
state.Add(S(3, 0x01000003u));
Assert.Equal(2, state.Entities.Count);
Assert.DoesNotContain(state.Entities, entity => entity.Id == 2);
Assert.Equal(0x01000003u, Assert.Single(state.Entities, entity => entity.Id == 3).SourceId);
}
[Fact]
public void Clear_RemovesCurrentProjectionAndIndex()
{
var state = new WorldGameState();
state.Add(S(1));
state.Clear();
state.Add(S(1, 0x01000001u));
WorldEntitySnapshot current = Assert.Single(state.Entities);
Assert.Equal(0x01000001u, current.SourceId);
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.IO;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
@ -100,6 +101,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var boundedDats = new DatCollectionAdapter(dats);
const uint setupId = 0x02000365u;
var setup = dats.Get<Setup>(setupId);
@ -115,7 +117,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
int survivors = 0, markerSkipped = 0, gfxNull = 0;
foreach (var mr in flat)
{
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(dats, mr.GfxObjId))
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(boundedDats, mr.GfxObjId))
{
markerSkipped++;
_out.WriteLine($" part gfx=0x{mr.GfxObjId:X8} -> MARKER (skipped)");
@ -164,6 +166,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
var datDir = ResolveDatDir();
if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var boundedDats = new DatCollectionAdapter(dats);
var setup = dats.Get<Setup>(setupId);
Assert.NotNull(setup);
@ -174,7 +177,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests
int survivors = 0;
foreach (var mr in flat)
{
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(dats, mr.GfxObjId)) continue;
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(boundedDats, mr.GfxObjId)) continue;
if (dats.Get<GfxObj>(mr.GfxObjId) is null) continue;
survivors++;
}

View file

@ -331,7 +331,6 @@ public class EntityClassificationCacheTests
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Physics;
@ -16,8 +15,8 @@ public sealed class EntitySpawnAdapterTests
[Fact]
public void OnCreate_ServerSpawnedEntity_RegistersAnimatedEntityState()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var lifetime = new RecordingTextureLifetime();
var adapter = MakeAdapter(lifetime);
var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0001u);
var state = adapter.OnCreate(entity);
@ -42,8 +41,8 @@ public sealed class EntitySpawnAdapterTests
[Fact]
public void OnCreate_ProceduralEntity_ReturnsNullAndRegistersNothing()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var lifetime = new RecordingTextureLifetime();
var adapter = MakeAdapter(lifetime);
// ServerGuid == 0 → atlas-tier, must not be processed here.
var entity = MakeEntity(id: 2, serverGuid: 0u);
@ -51,101 +50,19 @@ public sealed class EntitySpawnAdapterTests
Assert.Null(state);
Assert.Null(adapter.GetState(0u));
// No texture decode should have been triggered.
Assert.Empty(cache.Calls);
}
// ── Palette-override texture decode ───────────────────────────────────
[Fact]
public void OnCreate_WithPaletteOverrideAndSurfaceOverrides_TriggersTextureCacheDecode()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var palette = new PaletteOverride(
BasePaletteId: 0x04001234u,
SubPalettes: new[]
{
new PaletteOverride.SubPaletteRange(0x04002000u, 0, 2),
});
// Entity carries two parts each with one surface override.
var entity = new WorldEntity
{
Id = 10,
ServerGuid = 0xBEEF0001u,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
PaletteOverride = palette,
MeshRefs = new[]
{
new MeshRef(0x01000010u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
{ 0x08000100u, 0u }, // surfaceId → origTex (0 = none)
},
},
new MeshRef(0x01000020u, Matrix4x4.Identity)
{
SurfaceOverrides = new Dictionary<uint, uint>
{
{ 0x08000200u, 0x05000300u }, // with origTex override
},
},
},
};
adapter.OnCreate(entity);
// One call per surface-with-override: (0x08000100, null) and (0x08000200, 0x05000300).
Assert.Equal(2, cache.Calls.Count);
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000100u
&& c.OrigTexOverride == null
&& c.Palette == palette);
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000200u
&& c.OrigTexOverride == 0x05000300u
&& c.Palette == palette);
Assert.Empty(lifetime.ReleasedOwnerIds);
}
[Fact]
public void OnCreate_WithPaletteOverrideButNoSurfaceOverrides_DoesNotCallCache()
public void OnCreate_DoesNotAcquireTexturesBeforeTheSurfaceIsDrawn()
{
// Surfaces without SurfaceOverrides == null are decoded lazily at draw
// time; the adapter only pre-warms what it knows at spawn time.
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var entity = new WorldEntity
{
Id = 11,
ServerGuid = 0xBEEF0002u,
SourceGfxObjOrSetupId = 0x02000002u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
PaletteOverride = new PaletteOverride(0x04001235u, Array.Empty<PaletteOverride.SubPaletteRange>()),
// MeshRef with NO SurfaceOverrides.
MeshRefs = new[] { new MeshRef(0x01000011u, Matrix4x4.Identity) },
};
var lifetime = new RecordingTextureLifetime();
var adapter = MakeAdapter(lifetime);
var entity = MakeEntity(id: 10, serverGuid: 0xBEEF0001u);
adapter.OnCreate(entity);
Assert.Empty(cache.Calls);
}
[Fact]
public void OnCreate_WithoutPaletteOverride_DoesNotCallCache()
{
var cache = new CapturingTextureCache();
var adapter = MakeAdapter(cache);
var entity = MakeEntity(id: 12, serverGuid: 0xBEEF0003u);
adapter.OnCreate(entity);
Assert.Empty(cache.Calls);
Assert.Empty(lifetime.ReleasedOwnerIds);
}
// ── OnRemove ─────────────────────────────────────────────────────────
@ -153,14 +70,17 @@ public sealed class EntitySpawnAdapterTests
[Fact]
public void OnRemove_ReleasesPerEntityState()
{
var adapter = MakeAdapter();
var lifetime = new RecordingTextureLifetime();
var adapter = MakeAdapter(lifetime);
var entity = MakeEntity(id: 20, serverGuid: 0xCAFE0001u);
adapter.OnCreate(entity);
adapter.SetPresentationResident(entity, resident: true);
Assert.NotNull(adapter.GetState(0xCAFE0001u));
adapter.OnRemove(0xCAFE0001u);
Assert.Null(adapter.GetState(0xCAFE0001u));
Assert.Equal([20u], lifetime.ReleasedOwnerIds);
}
[Fact]
@ -208,10 +128,10 @@ public sealed class EntitySpawnAdapterTests
// ── Helpers ───────────────────────────────────────────────────────────
private static EntitySpawnAdapter MakeAdapter(ITextureCachePerInstance? cache = null)
private static EntitySpawnAdapter MakeAdapter(IEntityTextureLifetime? lifetime = null)
{
cache ??= new CapturingTextureCache();
return new EntitySpawnAdapter(cache, _ => MakeSequencer());
lifetime ??= new RecordingTextureLifetime();
return new EntitySpawnAdapter(lifetime, _ => MakeSequencer());
}
private static WorldEntity MakeEntity(uint id, uint serverGuid)
@ -230,23 +150,11 @@ public sealed class EntitySpawnAdapterTests
// ── Mocks / stubs ─────────────────────────────────────────────────────
/// <summary>
/// Capture every call to GetOrUploadWithPaletteOverride so tests can
/// assert without a live GL context.
/// </summary>
private sealed class CapturingTextureCache : ITextureCachePerInstance
private sealed class RecordingTextureLifetime : IEntityTextureLifetime
{
public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette);
public List<Call> Calls { get; } = new();
public List<uint> ReleasedOwnerIds { get; } = new();
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
{
Calls.Add(new Call(surfaceId, overrideOrigTextureId, paletteOverride));
return 1u; // Fake GL handle.
}
public void ReleaseOwner(uint localEntityId) => ReleasedOwnerIds.Add(localEntityId);
}
private sealed class NullAnimationLoader : IAnimationLoader

View file

@ -421,7 +421,6 @@ public sealed class WbDrawDispatcherBucketingTests
Vector3? localSortCenter = null)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,

View file

@ -307,6 +307,74 @@ public class LandblockStreamerTests
Assert.NotEqual(testThreadId, loaderThreadId.Value);
}
[Fact]
public async Task DisposeAndConcurrentDisposeWaitForInFlightLoad()
{
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
var streamer = new LandblockStreamer(loadLandblock: _ =>
{
entered.Set();
release.Wait();
return null;
});
try
{
streamer.Start();
streamer.EnqueueLoad(0x12340000u);
Assert.True(entered.Wait(TimeSpan.FromSeconds(2)));
Task firstDispose = Task.Run(streamer.Dispose);
Task secondDispose = Task.Run(streamer.Dispose);
await Task.Delay(50);
Assert.False(firstDispose.IsCompleted);
Assert.False(secondDispose.IsCompleted);
release.Set();
await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(2));
}
finally
{
release.Set();
streamer.Dispose();
}
}
[Fact]
public void DisposeRejectsEveryEnqueueKind()
{
var streamer = new LandblockStreamer(loadLandblock: _ => null);
streamer.Start();
streamer.Dispose();
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueLoad(0x12340000u));
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueUnload(0x12340000u));
Assert.Throws<ObjectDisposedException>(streamer.ClearPendingLoads);
}
[Fact]
public async Task ConcurrentStartAndDisposeLeaveAClosedStreamer()
{
for (int iteration = 0; iteration < 20; iteration++)
{
var streamer = new LandblockStreamer(loadLandblock: _ => null);
Exception? startFailure = null;
Task start = Task.Run(() =>
{
try { streamer.Start(); }
catch (ObjectDisposedException ex) { startFailure = ex; }
});
Task dispose = Task.Run(streamer.Dispose);
await Task.WhenAll(start, dispose).WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(startFailure is null or ObjectDisposedException);
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueLoad(0x12340000u));
streamer.Dispose();
}
}
private static async Task<LandblockStreamResult> DrainFirstAsync(LandblockStreamer streamer)
{
for (int i = 0; i < SpinMaxIterations; i++)

View file

@ -149,6 +149,58 @@ public class StreamingControllerPriorityApplyTests
Assert.Equal(3, applied.Count);
}
[Fact]
public void DeferredCompaction_ApplyFailureRetainsExactResultWithoutReplayingCommittedPrefix()
{
uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180);
uint first = StreamingRegion.EncodeLandblockIdForTest(169, 182);
uint retry = StreamingRegion.EncodeLandblockIdForTest(169, 183);
uint last = StreamingRegion.EncodeLandblockIdForTest(169, 184);
var outbox = new Queue<LandblockStreamResult>(
[
LoadedOf(first),
LoadedOf(retry),
LoadedOf(last),
]);
var applied = new List<uint>();
bool failRetryOnce = true;
var ctrl = new StreamingController(
enqueueLoad: (_, _) => { },
enqueueUnload: _ => { },
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && outbox.Count > 0)
batch.Add(outbox.Dequeue());
return batch;
},
applyTerrain: (build, _) =>
{
if (build.LandblockId == retry && failRetryOnce)
{
failRetryOnce = false;
throw new InvalidOperationException("synthetic upload failure");
}
applied.Add(build.LandblockId);
},
state: new GpuWorldState(),
nearRadius: 4,
farRadius: 12)
{
MaxCompletionsPerFrame = 3,
PriorityLandblockId = priority,
};
Assert.Throws<InvalidOperationException>(() => ctrl.Tick(169, 180));
Assert.Equal([first], applied);
Assert.Equal(2, ctrl.DeferredApplyBacklog);
ctrl.Tick(169, 180);
Assert.Equal([first, retry, last], applied);
Assert.Equal(0, ctrl.DeferredApplyBacklog);
}
[Fact]
public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow()
{

View file

@ -11,10 +11,15 @@ public class StreamingControllerTests
private sealed class FakeStreamer
{
public List<uint> Loads { get; } = new();
public List<(uint Id, LandblockStreamJobKind Kind)> LoadJobs { get; } = new();
public List<uint> Unloads { get; } = new();
public Queue<LandblockStreamResult> Pending { get; } = new();
public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id);
public void EnqueueLoad(uint id, LandblockStreamJobKind kind)
{
Loads.Add(id);
LoadJobs.Add((id, kind));
}
public void EnqueueUnload(uint id) => Unloads.Add(id);
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int max)
{
@ -65,6 +70,285 @@ public class StreamingControllerTests
Assert.Empty(fake.Unloads);
}
[Fact]
public void ReconfigureRadii_SmallerWindowUnloadsOnlyOutsideResidents()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
int pendingClears = 0;
var controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 2,
farRadius: 2,
clearPendingLoads: () => pendingClears++);
controller.Tick(50, 50);
for (int dx = -2; dx <= 2; dx++)
for (int dy = -2; dy <= 2; dy++)
AddPublished(state, 50 + dx, 50 + dy, LandblockStreamTier.Near);
fake.Loads.Clear();
fake.LoadJobs.Clear();
controller.ReconfigureRadii(0, 0);
Assert.Equal(1, pendingClears);
Assert.Empty(fake.Loads);
Assert.Equal(24, fake.Unloads.Count);
Assert.DoesNotContain(0x3232FFFFu, fake.Unloads);
}
[Fact]
public void ReconfigureRadii_LargerWindowLoadsOnlyMissingResidents()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
var controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0);
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.Loads.Clear();
fake.LoadJobs.Clear();
controller.ReconfigureRadii(1, 1);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.All(
fake.LoadJobs,
static job => Assert.Equal(LandblockStreamJobKind.LoadNear, job.Kind));
Assert.DoesNotContain(fake.LoadJobs, static job => job.Id == 0x3232FFFFu);
Assert.Empty(fake.Unloads);
}
[Fact]
public void ReconfigureRadii_UnchangedWindowDoesNotClearOrRebootstrap()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
int pendingClears = 0;
var controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 1,
farRadius: 2,
clearPendingLoads: () => pendingClears++);
controller.Tick(50, 50);
fake.Loads.Clear();
fake.LoadJobs.Clear();
controller.ReconfigureRadii(1, 2);
Assert.Equal(0, pendingClears);
Assert.Empty(fake.Loads);
Assert.Empty(fake.Unloads);
}
[Fact]
public void ReconfigureRadii_ClearFailureRetainsOldWindowAndResumesWithoutDuplicateLoads()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
int clearAttempts = 0;
var controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
clearPendingLoads: () =>
{
clearAttempts++;
if (clearAttempts == 1)
throw new InvalidOperationException("clear not admitted");
});
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.LoadJobs.Clear();
Assert.Throws<AggregateException>(() => controller.ReconfigureRadii(1, 1));
Assert.Equal(0, controller.NearRadius);
Assert.Equal(0, controller.FarRadius);
Assert.Empty(fake.LoadJobs);
controller.ReconfigureRadii(1, 1);
Assert.Equal(2, clearAttempts);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count());
Assert.Equal(1, controller.NearRadius);
Assert.Equal(1, controller.FarRadius);
}
[Fact]
public void ReconfigureRadii_QueueFailureRetriesOnlyUncommittedAdmission()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
int loadAttempts = 0;
bool failNext = false;
void EnqueueLoad(uint id, LandblockStreamJobKind kind)
{
loadAttempts++;
if (failNext)
{
failNext = false;
throw new InvalidOperationException("load not admitted");
}
fake.EnqueueLoad(id, kind);
}
var controller = new StreamingController(
EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0);
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.LoadJobs.Clear();
loadAttempts = 0;
failNext = true;
Assert.Throws<AggregateException>(() => controller.ReconfigureRadii(1, 1));
Assert.Equal(8, loadAttempts);
Assert.Equal(7, fake.LoadJobs.Count);
Assert.Equal(0, controller.NearRadius);
controller.ReconfigureRadii(1, 1);
Assert.Equal(9, loadAttempts);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count());
Assert.Equal(1, controller.NearRadius);
}
[Fact]
public void ReconfigureRadii_CommittedQueueFailureIsReportedButNeverReplayed()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
bool throwCommitted = false;
int loadAttempts = 0;
void EnqueueLoad(uint id, LandblockStreamJobKind kind)
{
loadAttempts++;
fake.EnqueueLoad(id, kind);
if (throwCommitted)
{
throwCommitted = false;
throw new StreamingMutationException(
"post-admission diagnostic failed",
mutationCommitted: true);
}
}
var controller = new StreamingController(
EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0);
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.LoadJobs.Clear();
loadAttempts = 0;
throwCommitted = true;
Assert.Throws<AggregateException>(() => controller.ReconfigureRadii(1, 1));
Assert.Equal(8, loadAttempts);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.Equal(1, controller.NearRadius);
controller.ReconfigureRadii(1, 1);
Assert.Equal(8, loadAttempts);
}
[Fact]
public void ReconfigureRadii_ClearCallbackReentryDefersWithoutRecursiveClear()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
StreamingController? controller = null;
int clearCalls = 0;
controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0,
clearPendingLoads: () =>
{
clearCalls++;
controller!.ReconfigureRadii(1, 1);
});
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.LoadJobs.Clear();
controller.ReconfigureRadii(1, 1);
Assert.Equal(1, clearCalls);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.Equal(1, controller.NearRadius);
}
[Fact]
public void ReconfigureRadii_QueueCallbackReentryDefersWithoutDuplicateAdmission()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
StreamingController? controller = null;
bool reenter = false;
int loadCalls = 0;
void EnqueueLoad(uint id, LandblockStreamJobKind kind)
{
loadCalls++;
fake.EnqueueLoad(id, kind);
if (reenter)
{
reenter = false;
controller!.ReconfigureRadii(1, 1);
}
}
controller = new StreamingController(
EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 0,
farRadius: 0);
controller.Tick(50, 50);
AddPublished(state, 50, 50, LandblockStreamTier.Near);
fake.LoadJobs.Clear();
loadCalls = 0;
reenter = true;
controller.ReconfigureRadii(1, 1);
Assert.Equal(8, loadCalls);
Assert.Equal(8, fake.LoadJobs.Count);
Assert.Equal(8, fake.LoadJobs.Select(static job => job.Id).Distinct().Count());
Assert.Equal(1, controller.NearRadius);
}
[Fact]
public void DrainingLoadedResult_AddsToState()
{
@ -115,4 +399,59 @@ public class StreamingControllerTests
Assert.False(state.IsLoaded(landblockId));
}
[Fact]
public void DrainingUnloadedResult_CommitsStateBeforePresentationTeardown()
{
var state = new GpuWorldState();
var fake = new FakeStreamer();
const uint landblockId = 0x2020FFFFu;
var lb = new LoadedLandblock(
landblockId,
new LandBlock(),
new[]
{
new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0x01000000u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
},
});
state.AddLandblock(lb);
bool callbackSawDetachedState = false;
var controller = new StreamingController(
fake.EnqueueLoad,
fake.EnqueueUnload,
fake.DrainCompletions,
(_, _) => { },
state,
nearRadius: 2,
farRadius: 2,
removeTerrain: id =>
{
callbackSawDetachedState = id == landblockId
&& !state.TryGetLandblock(id, out _);
});
fake.Pending.Enqueue(new LandblockStreamResult.Unloaded(landblockId));
controller.Tick(50, 50);
Assert.True(callbackSawDetachedState);
Assert.False(state.IsLoaded(landblockId));
}
private static void AddPublished(
GpuWorldState state,
int x,
int y,
LandblockStreamTier tier)
{
uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
state.AddLandblock(
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
tier: tier);
}
}

View file

@ -338,6 +338,58 @@ public sealed class ParticleHookSinkTests
Assert.Equal(1, deathNotices);
}
[Fact]
public void LogicalTeardown_AttemptsEveryEmitterWhenDeathSubscriberThrows()
{
const uint emitterId = 0x3200005Bu;
var (system, sink, _) = Harness(emitterId);
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 31u));
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 32u));
int deathAttempts = 0;
system.EmitterDied += _ =>
{
deathAttempts++;
throw new InvalidOperationException("observer failed after emitter removal");
};
AggregateException error = Assert.Throws<AggregateException>(
() => sink.StopAllForEntity(Owner, fadeOut: false));
Assert.Equal(2, error.InnerExceptions.Count);
Assert.Equal(2, deathAttempts);
Assert.Equal(0, system.ActiveEmitterCount);
Assert.Equal(0, sink.ActiveBindingCount);
Assert.Equal(0, sink.LogicalEmitterCount);
Assert.Equal(0, sink.TrackedOwnerCount);
// Every physical stop committed despite observer failures, so a retry
// is a clean no-op rather than replaying dead handles.
sink.StopAllForEntity(Owner, fadeOut: false);
Assert.Equal(2, deathAttempts);
}
[Fact]
public void LogicalTeardown_DeathCallbackCannotResurrectSameOwnerEmitter()
{
const uint emitterId = 0x3200005Cu;
var (system, sink, _) = Harness(emitterId);
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 41u));
int resurrectionAttempts = 0;
system.EmitterDied += _ =>
{
resurrectionAttempts++;
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 42u));
};
sink.StopAllForEntity(Owner, fadeOut: false);
Assert.Equal(1, resurrectionAttempts);
Assert.Equal(0, system.ActiveEmitterCount);
Assert.Equal(0, sink.ActiveBindingCount);
Assert.Equal(0, sink.LogicalEmitterCount);
Assert.Equal(0, sink.TrackedOwnerCount);
}
[Fact]
public void ExaminationObjectPolicyAppliesToExistingAndFutureEmittersAndTearsDown()
{
@ -362,17 +414,115 @@ public sealed class ParticleHookSinkTests
Assert.Equal(0, sink.ExaminationOwnerCount);
}
private sealed class MutablePoseSource : IEntityEffectPoseSource
[Fact]
public void PoseChangeWorkset_RefreshesOnlyChangedOwnersAndVisibilityEdges()
{
const uint emitterId = 0x32000059u;
const int ownerCount = 2_000;
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(emitterId, attachLocal: true));
var system = new ParticleSystem(registry, new Random(42));
var poses = new MutablePoseSource();
var sink = new ParticleHookSink(system, poses);
for (uint owner = 1; owner <= ownerCount; owner++)
{
poses.Publish(owner, Matrix4x4.Identity, Matrix4x4.Identity);
sink.OnHook(owner, Vector3.Zero, Create(emitterId, logicalId: 0));
}
// Spawn resolves the current pose directly; unchanged static owners
// create no recurring frame work.
sink.RefreshAttachedEmitters();
Assert.Equal(0, sink.LastRefreshOwnerVisitCount);
Assert.Equal(0, sink.LastRefreshBindingVisitCount);
poses.Publish(777u, Matrix4x4.CreateTranslation(1, 2, 3), Matrix4x4.Identity);
poses.Publish(777u, Matrix4x4.CreateTranslation(7, 8, 9), Matrix4x4.Identity);
sink.RefreshAttachedEmitters();
Assert.Equal(1, sink.LastRefreshOwnerVisitCount);
Assert.Equal(1, sink.LastRefreshBindingVisitCount);
Assert.Equal(new Vector3(7, 8, 9),
system.EnumerateEmitters().Single(emitter => emitter.AttachedObjectId == 777u).AnchorPos);
sink.RefreshAttachedEmitters();
Assert.Equal(0, sink.LastRefreshOwnerVisitCount);
sink.SetEntityPresentationVisible(777u, false);
sink.SetEntityPresentationVisible(777u, true);
sink.RefreshAttachedEmitters();
Assert.Equal(1, sink.LastRefreshOwnerVisitCount);
Assert.True(system.EnumerateEmitters()
.Single(emitter => emitter.AttachedObjectId == 777u)
.PresentationVisible);
}
[Fact]
public void PoseChangeRaisedDuringRefresh_IsRetainedForNextRefresh()
{
const uint emitterId = 0x3200005Au;
const uint firstOwner = 1u;
const uint secondOwner = 2u;
var registry = new EmitterDescRegistry();
registry.Register(MakeDesc(emitterId, attachLocal: true));
var system = new ParticleSystem(registry, new Random(42));
var poses = new MutablePoseSource();
poses.Publish(firstOwner, Matrix4x4.Identity, Matrix4x4.Identity);
poses.Publish(secondOwner, Matrix4x4.Identity, Matrix4x4.Identity);
var sink = new ParticleHookSink(system, poses);
sink.OnHook(firstOwner, Vector3.Zero, Create(emitterId, logicalId: 0));
sink.OnHook(secondOwner, Vector3.Zero, Create(emitterId, logicalId: 0));
bool raised = false;
poses.RootRead = owner =>
{
if (owner != firstOwner || raised)
return;
raised = true;
poses.Publish(
secondOwner,
Matrix4x4.CreateTranslation(20, 0, 0),
Matrix4x4.Identity);
};
poses.Publish(
firstOwner,
Matrix4x4.CreateTranslation(10, 0, 0),
Matrix4x4.Identity);
sink.RefreshAttachedEmitters();
Assert.Equal(1, sink.LastRefreshOwnerVisitCount);
sink.RefreshAttachedEmitters();
Assert.Equal(1, sink.LastRefreshOwnerVisitCount);
Assert.Equal(new Vector3(20, 0, 0),
system.EnumerateEmitters()
.Single(emitter => emitter.AttachedObjectId == secondOwner)
.AnchorPos);
}
private sealed class MutablePoseSource :
IEntityEffectPoseSource,
IEntityEffectPoseChangeSource
{
private readonly Dictionary<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _poses = new();
public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts) =>
_poses[id] = (root, parts);
public event Action<uint>? EffectPoseChanged;
public Action<uint>? RootRead { get; set; }
public void Remove(uint id) => _poses.Remove(id);
public void Publish(uint id, Matrix4x4 root, params Matrix4x4[] parts)
{
_poses[id] = (root, parts);
EffectPoseChanged?.Invoke(id);
}
public void Remove(uint id)
{
_poses.Remove(id);
EffectPoseChanged?.Invoke(id);
}
public bool TryGetRootPose(uint localEntityId, out Matrix4x4 rootWorld)
{
RootRead?.Invoke(localEntityId);
if (_poses.TryGetValue(localEntityId, out var pose))
{
rootWorld = pose.Root;

View file

@ -607,6 +607,196 @@ public sealed class ParticleSystemTests
Assert.All(sys.EnumerateEmitters(), emitter => Assert.True(emitter.ViewEligible));
}
[Fact]
public void DenseSuspendedSet_IsExcludedFromSimulationViewAndRenderWorksets()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000080u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 100f,
LifetimeMax = 100f,
};
var visibleCells = new HashSet<uint> { 0x01010001u };
for (int i = 0; i < 2_000; i++)
{
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: (uint)(i + 1));
sys.UpdateEmitterOwnerCell(handle, 0x01010001u);
if (i >= 3)
{
sys.SetEmitterPresentationVisible(handle, false);
sys.SetEmitterSimulationEnabled(handle, false);
}
}
sys.ApplyRetailView(Vector3.Zero, visibleCells, hasCompletedView: true);
sys.Tick(0.01f);
Assert.Equal(3, sys.LastRetailViewEmitterVisitCount);
Assert.Equal(3, sys.LastTickEmitterVisitCount);
Assert.Equal(3, sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene).Count());
Assert.Equal(2_000, sys.ActiveEmitterCount);
}
[Fact]
public void OwnerRenderScope_VisitsOnlyRequestedOwnersAndUnattachedEmittersInSpawnOrder()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000083u,
Type = ParticleType.Still,
MaxParticles = 1,
};
int unattached = sys.SpawnEmitter(desc, Vector3.Zero);
int ownerSeven = 0;
int ownerNine = 0;
for (uint owner = 1; owner <= 2_000; owner++)
{
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: owner);
if (owner == 7u)
ownerSeven = handle;
if (owner == 9u)
ownerNine = handle;
}
var destination = new List<ParticleEmitter>();
sys.CopyRenderableEmittersForOwners(
ParticleRenderPass.Scene,
new HashSet<uint> { 9u, 7u, 4_000u },
includeUnattached: true,
destination);
Assert.Equal(3, sys.LastRenderScopeEmitterVisitCount);
Assert.Equal(new[] { unattached, ownerSeven, ownerNine },
destination.Select(emitter => emitter.Handle));
sys.CopyRenderableEmittersForOwners(
ParticleRenderPass.Scene,
new HashSet<uint> { 7u, 9u },
includeUnattached: false,
destination,
excludedAttachedOwnerIds: new HashSet<uint> { 7u });
Assert.Equal(1, sys.LastRenderScopeEmitterVisitCount);
Assert.Equal(new[] { ownerNine }, destination.Select(emitter => emitter.Handle));
}
[Fact]
public void SpatialReentryWaitsForFreshRetailViewBeforeBecomingRenderable()
{
var sys = MakeSystem();
int handle = sys.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000084u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
},
Vector3.Zero,
attachedObjectId: 84u);
sys.UpdateEmitterOwnerCell(handle, 0x01010001u);
var visible = new HashSet<uint> { 0x01010001u };
var destination = new List<ParticleEmitter>();
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
Assert.Single(sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene));
sys.SetEmitterPresentationVisible(handle, false);
sys.SetEmitterSimulationEnabled(handle, false);
Assert.False(Assert.Single(sys.EnumerateEmitters()).ViewEligible);
sys.SetEmitterPresentationVisible(handle, true);
sys.SetEmitterSimulationEnabled(handle, true);
sys.CopyRenderableEmittersForOwners(
ParticleRenderPass.Scene,
new HashSet<uint> { 84u },
includeUnattached: false,
destination);
Assert.Empty(destination);
sys.ApplyRetailView(Vector3.Zero, visible, hasCompletedView: true);
sys.CopyRenderableEmittersForOwners(
ParticleRenderPass.Scene,
new HashSet<uint> { 84u },
includeUnattached: false,
destination);
Assert.Single(destination);
}
[Fact]
public void OrderedIndexes_PreserveSpawnOrderAcrossHardRemovalAndPassFiltering()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000081u,
Type = ParticleType.Still,
MaxParticles = 1,
};
int first = sys.SpawnEmitter(desc, Vector3.Zero);
int removedScene = sys.SpawnEmitter(desc, Vector3.Zero);
int sky = sys.SpawnEmitter(
desc,
Vector3.Zero,
renderPass: ParticleRenderPass.SkyPreScene,
visibilityPolicy: ParticleVisibilityPolicy.PassOwned);
int removedSky = sys.SpawnEmitter(
desc,
Vector3.Zero,
renderPass: ParticleRenderPass.SkyPreScene,
visibilityPolicy: ParticleVisibilityPolicy.PassOwned);
int last = sys.SpawnEmitter(desc, Vector3.Zero);
sys.StopEmitter(removedScene, fadeOut: false);
sys.StopEmitter(removedSky, fadeOut: false);
Assert.Equal(new[] { first, sky, last },
sys.EnumerateEmitters().Select(emitter => emitter.Handle));
Assert.Equal(new[] { first, last },
sys.EnumerateRenderableEmitters(ParticleRenderPass.Scene)
.Select(emitter => emitter.Handle));
Assert.Equal(new[] { sky },
sys.EnumerateRenderableEmitters(ParticleRenderPass.SkyPreScene)
.Select(emitter => emitter.Handle));
}
[Fact]
public void TickSnapshot_ToleratesNestedEmitterRemovalFromDeathCallback()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000082u,
Type = ParticleType.Still,
MaxParticles = 1,
InitialParticles = 1,
TotalParticles = 1,
LifetimeMin = 0.01f,
LifetimeMax = 0.01f,
Lifespan = 0.01f,
};
int first = sys.SpawnEmitter(desc, Vector3.Zero);
int second = sys.SpawnEmitter(desc, Vector3.Zero);
sys.EmitterDied += handle =>
{
if (handle == first)
sys.StopEmitter(second, fadeOut: false);
};
sys.Tick(0.02f);
sys.Tick(0.01f);
Assert.Equal(1, sys.LastTickEmitterVisitCount);
Assert.Equal(0, sys.ActiveEmitterCount);
Assert.Empty(sys.EnumerateEmitters());
}
[Fact]
public void FiniteEmitter_DegradedBranchExpiresAndRemovesIt()
{
@ -849,6 +1039,26 @@ public sealed class ParticleSystemTests
Assert.False(sys.IsEmitterAlive(handle));
}
[Fact]
public void EmitterDied_AttemptsEverySubscriberWhenOneThrows()
{
var sys = MakeSystem();
var delivered = new List<int>();
sys.EmitterDied += _ => throw new InvalidOperationException("first subscriber failed");
sys.EmitterDied += delivered.Add;
int handle = sys.SpawnEmitter(
MakeDesc(emitRate: 5f, lifetime: 0.2f, maxParticles: 4),
Vector3.Zero);
AggregateException failure = Assert.Throws<AggregateException>(
() => sys.StopEmitter(handle, fadeOut: false));
Assert.Contains("first subscriber failed", failure.ToString());
Assert.Equal([handle], delivered);
Assert.False(sys.IsEmitterAlive(handle));
}
[Fact]
public void Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed()
{

View file

@ -141,10 +141,10 @@ public class LandblockLoaderTests
var idsB = entitiesLbB.Select(e => e.Id).ToArray();
Assert.Empty(idsA.Intersect(idsB));
// The namespace top byte is 0xC0 for stabs (distinct from 0x80 scenery,
// 0x40 interior, low-range live entities).
Assert.All(idsA, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu));
Assert.All(idsB, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu));
// The namespace top nibble is 0xC for stabs (distinct from 0x8
// scenery, 0x4 interior, and low-range live entities).
Assert.All(idsA, id => Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(id)));
Assert.All(idsB, id => Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(id)));
}
[Fact]
@ -186,17 +186,34 @@ public class LandblockLoaderTests
Assert.Equal(1u, entities[0].Id);
}
[Fact]
public void BuildEntitiesFromInfo_MoreThan255EntriesStayInTheOwningLandblockRange()
{
var info = new LandBlockInfo();
for (uint i = 0; i < 300u; i++)
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
IReadOnlyList<WorldEntity> entities =
LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u);
Assert.Equal(300, entities.Count);
Assert.Equal(300, entities.Select(entity => entity.Id).Distinct().Count());
Assert.All(entities, entity =>
Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(entity.Id)));
Assert.True(entities[^1].Id < LandblockStaticEntityIdAllocator.Base(0xA9u, 0xB5u));
}
[Fact]
public void BuildEntitiesFromInfo_NamespacedIdOverflowFailsBeforeCrossLandblockAlias()
{
var info = new LandBlockInfo();
for (uint i = 0; i < 256u; i++)
for (uint i = 0; i <= LandblockStaticEntityIdAllocator.MaxCounter + 1u; i++)
info.Objects.Add(new Stab { Id = 0x01000001u, Frame = new Frame() });
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u));
Assert.Contains("255-entry", error.Message, StringComparison.Ordinal);
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
}
[Fact]

View file

@ -0,0 +1,46 @@
using AcDream.Core.World;
namespace AcDream.Core.Tests.World;
public sealed class LandblockStaticEntityIdAllocatorTests
{
[Fact]
public void Base_PreservesFullCoordinatesAndStabNamespace()
{
var bases = new HashSet<uint>();
for (uint y = 0; y <= 255; y++)
for (uint x = 0; x <= 255; x++)
{
uint value = LandblockStaticEntityIdAllocator.Base(x, y);
Assert.True(LandblockStaticEntityIdAllocator.IsInNamespace(value));
Assert.True(bases.Add(value), $"duplicate base for ({x},{y})");
}
}
[Fact]
public void MaximumCounterDoesNotAliasAdjacentLandblock()
{
for (uint y = 0; y < 255; y++)
{
uint current = LandblockStaticEntityIdAllocator.Base(0, y);
uint next = LandblockStaticEntityIdAllocator.Base(0, y + 1);
Assert.True(current + LandblockStaticEntityIdAllocator.MaxCounter < next);
}
}
[Fact]
public void Allocate_AcceptsLastCounterThenFailsBeforeNamespaceWrap()
{
uint counter = LandblockStaticEntityIdAllocator.MaxCounter;
uint last = LandblockStaticEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter);
Assert.Equal(
LandblockStaticEntityIdAllocator.Base(0xA9u, 0xB4u)
+ LandblockStaticEntityIdAllocator.MaxCounter,
last);
InvalidDataException error = Assert.Throws<InvalidDataException>(
() => LandblockStaticEntityIdAllocator.Allocate(0xA9u, 0xB4u, ref counter));
Assert.Contains("4096-entry", error.Message, StringComparison.Ordinal);
}
}

View file

@ -11,17 +11,17 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
public sealed class DisplaySettingsTests
{
[Fact]
public void Default_values_match_pre_L0_runtime_state()
public void Default_values_match_normal_client_policy()
{
// Defaults pinned to match the actual pre-L.0 startup state:
// Defaults pin the normal-client startup policy:
// · Resolution matches WindowOptions (1280×720 in GameWindow.Run)
// · FieldOfView matches camera FovY (60° = π/3)
// · VSync matches WindowOptions (false during dev)
// · VSync is on so normal presentation follows monitor refresh
// · ShowFps false matches retail's initially-hidden SmartBox meter
var d = DisplaySettings.Default;
Assert.Equal("1280x720", d.Resolution);
Assert.False(d.Fullscreen);
Assert.False(d.VSync);
Assert.True(d.VSync);
Assert.Equal(60f, d.FieldOfView);
Assert.Equal(1.0f, d.Gamma);
Assert.False(d.ShowFps);
@ -61,7 +61,7 @@ public sealed class DisplaySettingsTests
Assert.Equal(90f, d.FieldOfView);
// Other fields untouched.
Assert.Equal("1280x720", d.Resolution);
Assert.False(d.VSync);
Assert.True(d.VSync);
Assert.False(d.ShowFps);
}