fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AcDream.App.Tests.BoundedTestDatCollection" Alias="DatCollection" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
76
tests/AcDream.App.Tests/BoundedTestDatCollection.cs
Normal file
76
tests/AcDream.App.Tests/BoundedTestDatCollection.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 _));
|
||||
}
|
||||
}
|
||||
252
tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs
Normal file
252
tests/AcDream.App.Tests/Rendering/BuildingGroupScratchTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs
Normal file
130
tests/AcDream.App.Tests/Rendering/ClipFrameUploadTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs
Normal file
116
tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs
Normal file
63
tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
@ -81,4 +81,35 @@ public sealed class ContiguousRangeAllocatorTests
|
|||
Assert.Equal(small.Offset, selected.Offset);
|
||||
Assert.Equal(40, allocator.LargestFreeRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasingTailLowersLiveHighWaterAndAllowsTrim()
|
||||
{
|
||||
var allocator = new ContiguousRangeAllocator(100);
|
||||
Assert.True(allocator.TryAllocate(20, out _));
|
||||
Assert.True(allocator.TryAllocate(30, out MeshBufferRange tail));
|
||||
Assert.Equal(50, allocator.HighWaterMark);
|
||||
|
||||
allocator.Release(tail);
|
||||
|
||||
Assert.Equal(20, allocator.HighWaterMark);
|
||||
Assert.Equal(80, allocator.TrailingFreeLength);
|
||||
allocator.Shrink(40);
|
||||
Assert.Equal(40, allocator.Capacity);
|
||||
Assert.Equal(20, allocator.HighWaterMark);
|
||||
Assert.Equal(20, allocator.TrailingFreeLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteriorReleaseDoesNotHideAHighLiveAllocation()
|
||||
{
|
||||
var allocator = new ContiguousRangeAllocator(100);
|
||||
Assert.True(allocator.TryAllocate(20, out MeshBufferRange first));
|
||||
Assert.True(allocator.TryAllocate(30, out _));
|
||||
|
||||
allocator.Release(first);
|
||||
|
||||
Assert.Equal(50, allocator.HighWaterMark);
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.Shrink(40));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
const int reuseCount = 32;
|
||||
const uint guidBase = 0x74000000u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var textures = new RecordingTextureLifetime();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new NullTextureCache(),
|
||||
textures,
|
||||
_ => MakeSequencer(),
|
||||
meshes);
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
entity.PaletteOverride,
|
||||
[new PartOverride(0, 0x01001000u + (uint)i)]);
|
||||
Assert.NotNull(adapter.OnCreate(entity));
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
}
|
||||
|
||||
Assert.Equal(ownerCount * 2, meshes.TotalReferenceCount);
|
||||
|
|
@ -49,6 +51,7 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
replacement.PaletteOverride,
|
||||
[new PartOverride(0, 0x01002000u + (uint)i)]);
|
||||
Assert.NotNull(adapter.OnCreate(replacement));
|
||||
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
|
||||
}
|
||||
|
||||
for (int i = 0; i < ownerCount; i++)
|
||||
|
|
@ -61,6 +64,570 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Empty(meshes.ReferenceCounts);
|
||||
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
|
||||
Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Count);
|
||||
Assert.Equal(ownerCount + reuseCount, textures.ReleasedOwnerIds.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectionSuspendResume_IsIdempotentAndPreservesAnimatedState()
|
||||
{
|
||||
const uint guid = 0x74001000u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var textures = new RecordingTextureLifetime();
|
||||
int sequencerCreates = 0;
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
textures,
|
||||
_ =>
|
||||
{
|
||||
sequencerCreates++;
|
||||
return MakeSequencer();
|
||||
},
|
||||
meshes);
|
||||
WorldEntity entity = MakeEntity(41u, guid);
|
||||
entity.ApplyAppearance(
|
||||
[
|
||||
new MeshRef(0x01000001u, Matrix4x4.Identity),
|
||||
new MeshRef(0x01000001u, Matrix4x4.Identity),
|
||||
],
|
||||
entity.PaletteOverride,
|
||||
[new PartOverride(0, 0x01000002u)]);
|
||||
|
||||
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.False(adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.Empty(textures.ReleasedOwnerIds);
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.False(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(2, meshes.TotalReferenceCount);
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.False(adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal([entity.Id], textures.ReleasedOwnerIds);
|
||||
Assert.Same(state, adapter.GetState(guid));
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(2, meshes.TotalReferenceCount);
|
||||
Assert.Same(state, adapter.GetState(guid));
|
||||
Assert.Equal(1, sequencerCreates);
|
||||
|
||||
adapter.OnRemove(guid);
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal([entity.Id, entity.Id], textures.ReleasedOwnerIds);
|
||||
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveWhileSuspended_DoesNotReleasePresentationResourcesTwice()
|
||||
{
|
||||
const uint guid = 0x74002000u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var textures = new RecordingTextureLifetime();
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(51u, guid);
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
adapter.SetPresentationResident(entity, resident: true);
|
||||
adapter.SetPresentationResident(entity, resident: false);
|
||||
int decrementsAfterSuspend = meshes.DecrementCount;
|
||||
|
||||
adapter.OnRemove(guid);
|
||||
adapter.OnRemove(guid);
|
||||
|
||||
Assert.Null(adapter.GetState(guid));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(decrementsAfterSuspend, meshes.DecrementCount);
|
||||
Assert.Equal([entity.Id], textures.ReleasedOwnerIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuidReplacement_IgnoresDelayedEdgesFromDisplacedOwner()
|
||||
{
|
||||
const uint guid = 0x74003000u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var textures = new RecordingTextureLifetime();
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity oldEntity = MakeEntity(61u, guid);
|
||||
oldEntity.MeshRefs = [new MeshRef(0x01000100u, Matrix4x4.Identity)];
|
||||
WorldEntity replacement = MakeEntity(62u, guid);
|
||||
replacement.MeshRefs = [new MeshRef(0x01000200u, Matrix4x4.Identity)];
|
||||
|
||||
adapter.OnCreate(oldEntity);
|
||||
adapter.SetPresentationResident(oldEntity, resident: true);
|
||||
AnimatedEntityState replacementState = Assert.IsType<AnimatedEntityState>(
|
||||
adapter.OnCreate(replacement));
|
||||
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds);
|
||||
Assert.False(adapter.OnRemove(oldEntity));
|
||||
Assert.False(adapter.SetPresentationResident(oldEntity, resident: false));
|
||||
Assert.False(adapter.SetPresentationResident(oldEntity, resident: true));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
|
||||
Assert.Equal(1, meshes.TotalReferenceCount);
|
||||
Assert.Same(replacementState, adapter.GetState(guid));
|
||||
|
||||
adapter.OnRemove(guid);
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal([oldEntity.Id, replacement.Id], textures.ReleasedOwnerIds);
|
||||
Assert.Equal(meshes.IncrementCount, meshes.DecrementCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuidReplacement_FactoryFailure_PreservesResidentPriorOwner()
|
||||
{
|
||||
const uint guid = 0x74004000u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var textures = new RecordingTextureLifetime();
|
||||
WorldEntity oldEntity = MakeEntity(71u, guid);
|
||||
oldEntity.MeshRefs = [new MeshRef(0x01000300u, Matrix4x4.Identity)];
|
||||
WorldEntity replacement = MakeEntity(72u, guid);
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
textures,
|
||||
entity => ReferenceEquals(entity, replacement)
|
||||
? throw new InvalidOperationException("replacement factory failure")
|
||||
: MakeSequencer(),
|
||||
meshes);
|
||||
|
||||
AnimatedEntityState oldState = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(oldEntity));
|
||||
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => adapter.OnCreate(replacement));
|
||||
|
||||
Assert.Same(oldState, adapter.GetState(guid));
|
||||
Assert.Equal(1, meshes.TotalReferenceCount);
|
||||
Assert.Empty(textures.ReleasedOwnerIds);
|
||||
Assert.False(adapter.SetPresentationResident(replacement, resident: true));
|
||||
Assert.True(adapter.SetPresentationResident(oldEntity, resident: false));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal([oldEntity.Id], textures.ReleasedOwnerIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectionSuspend_PartialReleaseFailure_RemainsResidentAndRetriesOnlyUnfinishedResources()
|
||||
{
|
||||
const uint guid = 0x74005000u;
|
||||
const ulong firstMesh = 0x01000400u;
|
||||
const ulong secondMesh = 0x01000401u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var textures = new FaultInjectingTextureLifetime();
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(81u, guid);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef((uint)firstMesh, Matrix4x4.Identity),
|
||||
new MeshRef((uint)secondMesh, Matrix4x4.Identity),
|
||||
];
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
meshes.FailNextDecrement(secondMesh);
|
||||
|
||||
Assert.Throws<AggregateException>(
|
||||
() => adapter.SetPresentationResident(entity, resident: false));
|
||||
|
||||
Assert.NotNull(adapter.GetState(guid));
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(2, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, textures.AttemptCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[firstMesh]);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[secondMesh]);
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(2, textures.AttemptCount);
|
||||
Assert.Equal(2, meshes.DecrementAttempts[firstMesh]);
|
||||
Assert.Equal(2, meshes.DecrementAttempts[secondMesh]);
|
||||
Assert.False(adapter.SetPresentationResident(entity, resident: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogicalRemove_ReleaseFailure_KeepsOwnerReachableForRetry()
|
||||
{
|
||||
const uint guid = 0x74006000u;
|
||||
const ulong meshId = 0x01000500u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1);
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(91u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
|
||||
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
|
||||
Assert.Throws<AggregateException>(() => adapter.OnRemove(entity));
|
||||
|
||||
Assert.Same(state, adapter.GetState(guid));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
|
||||
Assert.Equal(1, textures.AttemptCount);
|
||||
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Null(adapter.GetState(guid));
|
||||
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
|
||||
Assert.Equal(2, textures.AttemptCount);
|
||||
Assert.Equal(1, textures.SuccessCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GuidReplacement_ReleaseFailure_PublishesReplacementOnlyAfterRetrySucceeds()
|
||||
{
|
||||
const uint guid = 0x74007000u;
|
||||
const ulong oldMeshId = 0x01000600u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var textures = new FaultInjectingTextureLifetime(failuresBeforeSuccess: 1);
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity oldEntity = MakeEntity(101u, guid);
|
||||
oldEntity.MeshRefs = [new MeshRef((uint)oldMeshId, Matrix4x4.Identity)];
|
||||
WorldEntity replacement = MakeEntity(102u, guid);
|
||||
AnimatedEntityState oldState = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(oldEntity));
|
||||
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
|
||||
|
||||
Assert.Throws<AggregateException>(() => adapter.OnCreate(replacement));
|
||||
|
||||
Assert.Same(oldState, adapter.GetState(guid));
|
||||
Assert.False(adapter.SetPresentationResident(replacement, resident: true));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]);
|
||||
|
||||
AnimatedEntityState replacementState = Assert.IsType<AnimatedEntityState>(
|
||||
adapter.OnCreate(replacement));
|
||||
Assert.Same(replacementState, adapter.GetState(guid));
|
||||
Assert.False(adapter.OnRemove(oldEntity));
|
||||
Assert.Equal(1, meshes.DecrementAttempts[oldMeshId]);
|
||||
Assert.Equal(2, textures.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogicalRemove_ReentrantDuplicate_DoesNotPublishRemovalBeforeCleanupCompletes()
|
||||
{
|
||||
const uint guid = 0x74008000u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
EntitySpawnAdapter? adapter = null;
|
||||
WorldEntity entity = MakeEntity(111u, guid);
|
||||
bool invokeNestedRemove = true;
|
||||
var textures = new FaultInjectingTextureLifetime(
|
||||
onRelease: () =>
|
||||
{
|
||||
if (!invokeNestedRemove)
|
||||
return;
|
||||
invokeNestedRemove = false;
|
||||
Assert.NotNull(adapter!.GetState(guid));
|
||||
adapter.OnRemove(entity);
|
||||
});
|
||||
adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
|
||||
AggregateException error = Assert.Throws<AggregateException>(() => adapter.OnRemove(entity));
|
||||
Assert.Contains(
|
||||
error.Flatten().InnerExceptions,
|
||||
exception => exception is EntityPresentationRemovalDeferredException);
|
||||
Assert.NotNull(adapter.GetState(guid));
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
|
||||
Assert.Null(adapter.GetState(guid));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(2, textures.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogicalRemove_AfterResumeRollbackFailure_ReleasesResidualMeshReference()
|
||||
{
|
||||
const uint guid = 0x74009000u;
|
||||
const ulong firstMesh = 0x01000700u;
|
||||
const ulong secondMesh = 0x01000701u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var textures = new FaultInjectingTextureLifetime();
|
||||
var adapter = new EntitySpawnAdapter(textures, _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(121u, guid);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef((uint)firstMesh, Matrix4x4.Identity),
|
||||
new MeshRef((uint)secondMesh, Matrix4x4.Identity),
|
||||
];
|
||||
AnimatedEntityState state = Assert.IsType<AnimatedEntityState>(adapter.OnCreate(entity));
|
||||
meshes.FailNextIncrement(secondMesh);
|
||||
meshes.FailNextDecrement(firstMesh);
|
||||
|
||||
Assert.Throws<AggregateException>(
|
||||
() => adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(1, meshes.TotalReferenceCount);
|
||||
Assert.Same(state, adapter.GetState(guid));
|
||||
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
|
||||
Assert.Null(adapter.GetState(guid));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(2, meshes.DecrementAttempts[firstMesh]);
|
||||
Assert.Equal(0, textures.AttemptCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resume_IncrementThrowsAfterCommit_RollsBackCommittedReference()
|
||||
{
|
||||
const uint guid = 0x7400A000u;
|
||||
const ulong meshId = 0x01000800u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(131u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
meshes.ThrowAfterNextIncrement(meshId);
|
||||
|
||||
Assert.Throws<MeshReferenceMutationException>(
|
||||
() => adapter.SetPresentationResident(entity, resident: true));
|
||||
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(1, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspend_DecrementThrowsAfterCommit_RetryDoesNotDoubleDecrement()
|
||||
{
|
||||
const uint guid = 0x7400B000u;
|
||||
const ulong meshId = 0x01000900u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new FaultInjectingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(141u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)meshId, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
meshes.ThrowAfterNextDecrement(meshId);
|
||||
|
||||
Assert.Throws<AggregateException>(
|
||||
() => adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
|
||||
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: false));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.Equal(1, meshes.DecrementAttempts[meshId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_Resident_AcquiresReplacementBeforePublicationAndRetiresOldAfter()
|
||||
{
|
||||
const uint guid = 0x7400C000u;
|
||||
const ulong oldMesh = 0x01000A00u;
|
||||
const ulong newMesh = 0x01000A01u;
|
||||
const ulong overrideMesh = 0x01000A02u;
|
||||
var meshes = new CallbackMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(151u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
meshes.Operations.Clear();
|
||||
|
||||
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
|
||||
PartOverride[] overrides = [new PartOverride(0, (uint)overrideMesh)];
|
||||
Assert.True(adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
replacement,
|
||||
overrides,
|
||||
() =>
|
||||
{
|
||||
Assert.Equal(1, meshes.ReferenceCounts[oldMesh]);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]);
|
||||
meshes.Operations.Add("publish");
|
||||
entity.ApplyAppearance(replacement, paletteOverride: null, overrides);
|
||||
}));
|
||||
|
||||
int publicationIndex = meshes.Operations.IndexOf("publish");
|
||||
Assert.True(meshes.Operations.IndexOf($"increment:{newMesh:X8}") < publicationIndex);
|
||||
Assert.True(meshes.Operations.IndexOf($"increment:{overrideMesh:X8}") < publicationIndex);
|
||||
Assert.True(meshes.Operations.IndexOf($"decrement:{oldMesh:X8}") > publicationIndex);
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
|
||||
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[overrideMesh]);
|
||||
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_WhileSuspended_OnlyReplacementSetIsAcquiredOnResume()
|
||||
{
|
||||
const uint guid = 0x7400D000u;
|
||||
const ulong oldMesh = 0x01000B00u;
|
||||
const ulong newMesh = 0x01000B01u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(161u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
|
||||
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
|
||||
Assert.True(adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
replacement,
|
||||
[],
|
||||
() => entity.ApplyAppearance(replacement, paletteOverride: null, [])));
|
||||
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
|
||||
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_CommittedAcquireFailure_RollsBackAndDoesNotPublish()
|
||||
{
|
||||
const uint guid = 0x7400E000u;
|
||||
const ulong oldMesh = 0x01000C00u;
|
||||
const ulong newMesh = 0x01000C01u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(171u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
meshes.ThrowAfterNextIncrement(newMesh);
|
||||
bool published = false;
|
||||
|
||||
Assert.Throws<MeshReferenceMutationException>(() =>
|
||||
adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
[new MeshRef((uint)newMesh, Matrix4x4.Identity)],
|
||||
[],
|
||||
() => published = true));
|
||||
|
||||
Assert.False(published);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[oldMesh]);
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(newMesh));
|
||||
Assert.Equal(1, meshes.DecrementAttempts[newMesh]);
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_ReleaseFailure_RetainsMarkerForResidencyRetry()
|
||||
{
|
||||
const uint guid = 0x7400F000u;
|
||||
const ulong oldMesh = 0x01000D00u;
|
||||
const ulong newMesh = 0x01000D01u;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(181u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
meshes.FailNextDecrement(oldMesh);
|
||||
int publications = 0;
|
||||
MeshRef[] replacement = [new MeshRef((uint)newMesh, Matrix4x4.Identity)];
|
||||
|
||||
Assert.Throws<AggregateException>(() =>
|
||||
adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
replacement,
|
||||
[],
|
||||
() =>
|
||||
{
|
||||
publications++;
|
||||
entity.ApplyAppearance(replacement, paletteOverride: null, []);
|
||||
}));
|
||||
|
||||
Assert.Equal(1, publications);
|
||||
Assert.Equal(2, meshes.TotalReferenceCount);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
Assert.Equal(1, publications);
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
|
||||
Assert.Equal(1, meshes.ReferenceCounts[newMesh]);
|
||||
Assert.Equal(2, meshes.DecrementAttempts[oldMesh]);
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_ReentrantRequestCannotPublishInsideOuterTransition()
|
||||
{
|
||||
const uint guid = 0x74010000u;
|
||||
const ulong oldMesh = 0x01000E00u;
|
||||
const ulong outerMesh = 0x01000E01u;
|
||||
const ulong nestedMesh = 0x01000E02u;
|
||||
var meshes = new CallbackMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity entity = MakeEntity(191u, guid);
|
||||
entity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(entity);
|
||||
Assert.True(adapter.SetPresentationResident(entity, resident: true));
|
||||
bool nestedPublished = false;
|
||||
bool nestedResult = true;
|
||||
meshes.AfterIncrement = meshId =>
|
||||
{
|
||||
if (meshId != outerMesh)
|
||||
return;
|
||||
|
||||
nestedResult = adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
[new MeshRef((uint)nestedMesh, Matrix4x4.Identity)],
|
||||
[],
|
||||
() => nestedPublished = true);
|
||||
};
|
||||
|
||||
MeshRef[] replacement = [new MeshRef((uint)outerMesh, Matrix4x4.Identity)];
|
||||
Assert.True(adapter.OnAppearanceChanged(
|
||||
entity,
|
||||
replacement,
|
||||
[],
|
||||
() => entity.ApplyAppearance(replacement, paletteOverride: null, [])));
|
||||
|
||||
Assert.False(nestedResult);
|
||||
Assert.False(nestedPublished);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[outerMesh]);
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(nestedMesh));
|
||||
Assert.True(adapter.OnRemove(entity));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppearanceChange_DelayedOldIncarnationCannotAffectGuidReplacement()
|
||||
{
|
||||
const uint guid = 0x74011000u;
|
||||
const ulong oldMesh = 0x01000F00u;
|
||||
const ulong replacementMesh = 0x01000F01u;
|
||||
const ulong staleMesh = 0x01000F02u;
|
||||
var meshes = new RefCountingMeshAdapter();
|
||||
var adapter = new EntitySpawnAdapter(
|
||||
new RecordingTextureLifetime(), _ => MakeSequencer(), meshes);
|
||||
WorldEntity oldEntity = MakeEntity(201u, guid);
|
||||
oldEntity.MeshRefs = [new MeshRef((uint)oldMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(oldEntity);
|
||||
Assert.True(adapter.SetPresentationResident(oldEntity, resident: true));
|
||||
|
||||
WorldEntity replacement = MakeEntity(202u, guid);
|
||||
replacement.MeshRefs = [new MeshRef((uint)replacementMesh, Matrix4x4.Identity)];
|
||||
adapter.OnCreate(replacement);
|
||||
Assert.True(adapter.SetPresentationResident(replacement, resident: true));
|
||||
bool stalePublished = false;
|
||||
|
||||
Assert.False(adapter.OnAppearanceChanged(
|
||||
oldEntity,
|
||||
[new MeshRef((uint)staleMesh, Matrix4x4.Identity)],
|
||||
[],
|
||||
() => stalePublished = true));
|
||||
|
||||
Assert.False(stalePublished);
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(oldMesh));
|
||||
Assert.False(meshes.ReferenceCounts.ContainsKey(staleMesh));
|
||||
Assert.Equal(1, meshes.ReferenceCounts[replacementMesh]);
|
||||
Assert.True(adapter.OnRemove(replacement));
|
||||
Assert.Equal(0, meshes.TotalReferenceCount);
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint id, uint serverGuid) => new()
|
||||
|
|
@ -81,12 +648,11 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
|
||||
private sealed class NullTextureCache : ITextureCachePerInstance
|
||||
private sealed class RecordingTextureLifetime : IEntityTextureLifetime
|
||||
{
|
||||
public uint GetOrUploadWithPaletteOverride(
|
||||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride) => 1u;
|
||||
public List<uint> ReleasedOwnerIds { get; } = new();
|
||||
|
||||
public void ReleaseOwner(uint localEntityId) => ReleasedOwnerIds.Add(localEntityId);
|
||||
}
|
||||
|
||||
private sealed class RefCountingMeshAdapter : IWbMeshAdapter
|
||||
|
|
@ -113,4 +679,118 @@ public sealed class EntitySpawnAdapterLifetimeTests
|
|||
DecrementCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CallbackMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||
public List<string> Operations { get; } = new();
|
||||
public Action<ulong>? AfterIncrement { get; set; }
|
||||
public int TotalReferenceCount => ReferenceCounts.Values.Sum();
|
||||
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
|
||||
Operations.Add($"increment:{id:X8}");
|
||||
AfterIncrement?.Invoke(id);
|
||||
}
|
||||
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
Assert.True(ReferenceCounts.TryGetValue(id, out int count));
|
||||
Assert.True(count > 0);
|
||||
if (count == 1)
|
||||
ReferenceCounts.Remove(id);
|
||||
else
|
||||
ReferenceCounts[id] = count - 1;
|
||||
Operations.Add($"decrement:{id:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FaultInjectingTextureLifetime(
|
||||
int failuresBeforeSuccess = 0,
|
||||
Action? onRelease = null) : IEntityTextureLifetime
|
||||
{
|
||||
private int _failuresRemaining = failuresBeforeSuccess;
|
||||
|
||||
public int AttemptCount { get; private set; }
|
||||
public int SuccessCount { get; private set; }
|
||||
|
||||
public void ReleaseOwner(uint localEntityId)
|
||||
{
|
||||
AttemptCount++;
|
||||
onRelease?.Invoke();
|
||||
if (_failuresRemaining > 0)
|
||||
{
|
||||
_failuresRemaining--;
|
||||
throw new InvalidOperationException("injected texture release failure");
|
||||
}
|
||||
|
||||
SuccessCount++;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
private readonly Dictionary<ulong, int> _remainingIncrementFailures = new();
|
||||
private readonly Dictionary<ulong, int> _remainingDecrementFailures = new();
|
||||
private readonly HashSet<ulong> _throwAfterIncrement = new();
|
||||
private readonly HashSet<ulong> _throwAfterDecrement = new();
|
||||
|
||||
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||
public Dictionary<ulong, int> DecrementAttempts { get; } = new();
|
||||
public int TotalReferenceCount => ReferenceCounts.Values.Sum();
|
||||
|
||||
public void FailNextIncrement(ulong id) =>
|
||||
_remainingIncrementFailures[id] =
|
||||
_remainingIncrementFailures.GetValueOrDefault(id) + 1;
|
||||
|
||||
public void FailNextDecrement(ulong id) =>
|
||||
_remainingDecrementFailures[id] =
|
||||
_remainingDecrementFailures.GetValueOrDefault(id) + 1;
|
||||
|
||||
public void ThrowAfterNextIncrement(ulong id) => _throwAfterIncrement.Add(id);
|
||||
public void ThrowAfterNextDecrement(ulong id) => _throwAfterDecrement.Add(id);
|
||||
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
if (_remainingIncrementFailures.GetValueOrDefault(id) > 0)
|
||||
{
|
||||
_remainingIncrementFailures[id]--;
|
||||
throw new InvalidOperationException("injected mesh acquire failure");
|
||||
}
|
||||
|
||||
ReferenceCounts[id] = ReferenceCounts.GetValueOrDefault(id) + 1;
|
||||
if (_throwAfterIncrement.Remove(id))
|
||||
{
|
||||
throw new MeshReferenceMutationException(
|
||||
"injected post-commit mesh acquire failure",
|
||||
mutationCommitted: true,
|
||||
new InvalidOperationException("injected metadata failure"));
|
||||
}
|
||||
}
|
||||
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
DecrementAttempts[id] = DecrementAttempts.GetValueOrDefault(id) + 1;
|
||||
if (_remainingDecrementFailures.GetValueOrDefault(id) > 0)
|
||||
{
|
||||
_remainingDecrementFailures[id]--;
|
||||
throw new InvalidOperationException("injected mesh release failure");
|
||||
}
|
||||
|
||||
Assert.True(ReferenceCounts.TryGetValue(id, out int count));
|
||||
Assert.True(count > 0);
|
||||
if (count == 1)
|
||||
ReferenceCounts.Remove(id);
|
||||
else
|
||||
ReferenceCounts[id] = count - 1;
|
||||
if (_throwAfterDecrement.Remove(id))
|
||||
{
|
||||
throw new MeshReferenceMutationException(
|
||||
"injected post-commit mesh release failure",
|
||||
mutationCommitted: true,
|
||||
new InvalidOperationException("injected cancellation failure"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,49 @@ namespace AcDream.App.Tests.Rendering.Wb;
|
|||
|
||||
public class EnvCellRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void OrderedMdiRanges_CoalesceAdjacentCellsWithIdenticalState()
|
||||
{
|
||||
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
||||
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 3, commandCount: 4);
|
||||
|
||||
Assert.Equal(
|
||||
[new EnvCellRenderer.MdiDrawRange(GroupIndex: 2, FirstCommand: 0, CommandCount: 7)],
|
||||
ranges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedMdiRanges_PreserveStateAndCommandGapsAsBoundaries()
|
||||
{
|
||||
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
||||
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 3);
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 6, firstCommand: 3, commandCount: 2);
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 5, commandCount: 1);
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 9, commandCount: 2);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new EnvCellRenderer.MdiDrawRange(2, 0, 3),
|
||||
new EnvCellRenderer.MdiDrawRange(6, 3, 2),
|
||||
new EnvCellRenderer.MdiDrawRange(2, 5, 1),
|
||||
new EnvCellRenderer.MdiDrawRange(2, 9, 2),
|
||||
],
|
||||
ranges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderedMdiRanges_IgnoreEmptyCellRanges()
|
||||
{
|
||||
var ranges = new List<EnvCellRenderer.MdiDrawRange>();
|
||||
|
||||
EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 2, firstCommand: 0, commandCount: 0);
|
||||
|
||||
Assert.Empty(ranges);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEnvCellGeomId — verbatim port of WB EnvCellRenderManager.cs:94-103
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class GpuRetiredRangeAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReleasedRangeCannotBeOverwrittenBeforeGpuRetirement()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue();
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange first));
|
||||
|
||||
allocator.ReleaseAfterGpuUse(first);
|
||||
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
Assert.False(allocator.TryAllocate(8, out _));
|
||||
retirement.RunAll();
|
||||
Assert.Equal(0, allocator.PendingReleaseCount);
|
||||
Assert.Equal(0, allocator.PendingReleaseLength);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
|
||||
Assert.Equal(first, reused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedUploadCanReleaseUnsubmittedRangeImmediately()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue();
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange first));
|
||||
|
||||
allocator.ReleaseUnsubmitted(first);
|
||||
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
|
||||
Assert.Equal(first, reused);
|
||||
Assert.Equal(0, retirement.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRetirement_KeepsPendingOwnershipAccounting()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue();
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
|
||||
|
||||
allocator.ReleaseAfterGpuUse(range);
|
||||
retirement.RunAll();
|
||||
allocator.ReleaseAfterGpuUse(range); // injected duplicate makes Release fail before commit
|
||||
|
||||
Assert.Throws<InvalidOperationException>(retirement.RunAll);
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublicationFailure_RetryDoesNotDuplicatePendingAccounting()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue { FailNextPublication = true };
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.ReleaseAfterGpuUse(range));
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
Assert.Equal(0, retirement.Count);
|
||||
|
||||
allocator.ReleaseAfterGpuUse(range);
|
||||
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
Assert.Equal(1, retirement.Count);
|
||||
retirement.RunAll();
|
||||
Assert.Equal(0, allocator.PendingReleaseCount);
|
||||
Assert.Equal(0, allocator.PendingReleaseLength);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange reused));
|
||||
Assert.Equal(range, reused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateReleaseBeforeRetirement_DoesNotPublishTwice()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue();
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
|
||||
|
||||
allocator.ReleaseAfterGpuUse(range);
|
||||
allocator.ReleaseAfterGpuUse(range);
|
||||
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
Assert.Equal(1, retirement.Count);
|
||||
retirement.RunAll();
|
||||
Assert.Equal(0, allocator.PendingReleaseCount);
|
||||
Assert.Equal(0, allocator.PendingReleaseLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingSubmittedRange_CannotBeReleasedAsUnsubmitted()
|
||||
{
|
||||
var retirement = new DeferredRetirementQueue();
|
||||
var allocator = new GpuRetiredRangeAllocator(capacity: 8, retirement);
|
||||
Assert.True(allocator.TryAllocate(8, out MeshBufferRange range));
|
||||
allocator.ReleaseAfterGpuUse(range);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => allocator.ReleaseUnsubmitted(range));
|
||||
Assert.Equal(1, allocator.PendingReleaseCount);
|
||||
Assert.Equal(8, allocator.PendingReleaseLength);
|
||||
}
|
||||
|
||||
private sealed class DeferredRetirementQueue : IGpuResourceRetirementQueue
|
||||
{
|
||||
private readonly List<Action> _releases = [];
|
||||
|
||||
public int Count => _releases.Count;
|
||||
public bool FailNextPublication { get; set; }
|
||||
|
||||
public void Retire(Action release)
|
||||
{
|
||||
if (FailNextPublication)
|
||||
{
|
||||
FailNextPublication = false;
|
||||
throw new InvalidOperationException("Injected publication failure.");
|
||||
}
|
||||
|
||||
_releases.Add(release);
|
||||
}
|
||||
|
||||
public void RunAll()
|
||||
{
|
||||
foreach (Action release in _releases)
|
||||
release();
|
||||
_releases.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,313 @@ public sealed class LandblockSpawnAdapterReadinessTests
|
|||
Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences()
|
||||
{
|
||||
const ulong first = 0x01000010ul;
|
||||
const ulong failed = 0x01000020ul;
|
||||
const ulong last = 0x01000030ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
meshes.FailNext(ReferenceOperation.Increment, failed, committed: false);
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => adapter.OnLandblockLoaded(landblock));
|
||||
|
||||
Assert.Equal(1, meshes.ReferenceCounts[first]);
|
||||
Assert.DoesNotContain(failed, meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[last]);
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
|
||||
Assert.Equal(1, meshes.ReferenceCounts[first]);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[failed]);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[last]);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, first));
|
||||
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Increment, failed));
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, last));
|
||||
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CommittedPreparedPinFailure_RetryDoesNotDoubleAcquire()
|
||||
{
|
||||
const ulong prepared = 0x2_0000_1234ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
meshes.FailNext(ReferenceOperation.PinPrepared, prepared, committed: true);
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu);
|
||||
|
||||
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
|
||||
() => adapter.OnLandblockLoaded(landblock, new[] { prepared }));
|
||||
|
||||
Assert.True(error.MutationCommitted);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[prepared]);
|
||||
|
||||
adapter.OnLandblockLoaded(landblock, new[] { prepared });
|
||||
|
||||
Assert.Equal(1, meshes.ReferenceCounts[prepared]);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.PinPrepared, prepared));
|
||||
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
Assert.Empty(meshes.ReferenceCounts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CommittedIncrementFailure_RetryDoesNotDoubleAcquire()
|
||||
{
|
||||
const ulong id = 0x01000010ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
meshes.FailNext(ReferenceOperation.Increment, id, committed: true);
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id);
|
||||
|
||||
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
|
||||
() => adapter.OnLandblockLoaded(landblock));
|
||||
|
||||
Assert.True(error.MutationCommitted);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[id]);
|
||||
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
|
||||
Assert.Equal(1, meshes.ReferenceCounts[id]);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, id));
|
||||
Assert.True(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unload_BeforeCommitFailure_RetryReleasesOnlyStillHeldReferences()
|
||||
{
|
||||
const ulong first = 0x01000010ul;
|
||||
const ulong failed = 0x01000020ul;
|
||||
const ulong last = 0x01000030ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)first, (uint)failed, (uint)last);
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
meshes.FailNext(ReferenceOperation.Decrement, failed, committed: false);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
|
||||
|
||||
Assert.Single(meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[failed]);
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
|
||||
Assert.Empty(meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, first));
|
||||
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed));
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, last));
|
||||
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unload_CommittedFailure_DropsCompletedRegistrationBeforePropagating()
|
||||
{
|
||||
const ulong id = 0x01000010ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)id);
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
meshes.FailNext(ReferenceOperation.Decrement, id, committed: true);
|
||||
|
||||
MeshReferenceMutationException error = Assert.Throws<MeshReferenceMutationException>(
|
||||
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
|
||||
|
||||
Assert.True(error.MutationCommitted);
|
||||
Assert.Empty(meshes.ReferenceCounts);
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OppositeUnloadAfterPartialLoad_ReleasesOnlyReferencesActuallyAcquired()
|
||||
{
|
||||
const ulong held = 0x01000010ul;
|
||||
const ulong neverAcquired = 0x01000020ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
meshes.FailNext(ReferenceOperation.Increment, neverAcquired, committed: false);
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(
|
||||
0x1234FFFFu,
|
||||
(uint)held,
|
||||
(uint)neverAcquired);
|
||||
Assert.Throws<InvalidOperationException>(() => adapter.OnLandblockLoaded(landblock));
|
||||
|
||||
adapter.OnLandblockUnloaded(landblock.LandblockId);
|
||||
|
||||
Assert.Empty(meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, held));
|
||||
Assert.Equal(0, meshes.CallCount(ReferenceOperation.Decrement, neverAcquired));
|
||||
Assert.False(adapter.IsLandblockRenderReady(landblock.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OppositeLoadAfterPartialUnload_ReusesOverlapAndReconcilesObsoleteResidual()
|
||||
{
|
||||
const ulong released = 0x01000010ul;
|
||||
const ulong residual = 0x01000020ul;
|
||||
const ulong newlyDesired = 0x01000030ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock original = MakeLandblock(
|
||||
0x1234FFFFu,
|
||||
(uint)released,
|
||||
(uint)residual);
|
||||
adapter.OnLandblockLoaded(original);
|
||||
meshes.FailNext(ReferenceOperation.Decrement, residual, committed: false);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => adapter.OnLandblockUnloaded(original.LandblockId));
|
||||
|
||||
// The residual id is intentionally absent from the replacement. The
|
||||
// reverse edge must finish its old release while acquiring the new
|
||||
// snapshot, without reacquiring the already-released first id.
|
||||
LoadedLandblock replacement = MakeLandblock(
|
||||
original.LandblockId,
|
||||
(uint)newlyDesired);
|
||||
adapter.OnLandblockLoaded(replacement);
|
||||
|
||||
Assert.Single(meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[newlyDesired]);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, released));
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residual));
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, newlyDesired));
|
||||
Assert.Equal(2, meshes.CallCount(ReferenceOperation.Decrement, residual));
|
||||
Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OppositeLoadAfterPartialUnload_RetainsStillHeldOverlapWithoutMutation()
|
||||
{
|
||||
const ulong released = 0x01000010ul;
|
||||
const ulong residualOverlap = 0x01000020ul;
|
||||
var meshes = new FaultInjectingMeshAdapter();
|
||||
var adapter = new LandblockSpawnAdapter(meshes);
|
||||
LoadedLandblock landblock = MakeLandblock(
|
||||
0x1234FFFFu,
|
||||
(uint)released,
|
||||
(uint)residualOverlap);
|
||||
adapter.OnLandblockLoaded(landblock);
|
||||
meshes.FailNext(ReferenceOperation.Decrement, residualOverlap, committed: false);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => adapter.OnLandblockUnloaded(landblock.LandblockId));
|
||||
|
||||
LoadedLandblock replacement = MakeLandblock(
|
||||
landblock.LandblockId,
|
||||
(uint)residualOverlap);
|
||||
adapter.OnLandblockLoaded(replacement);
|
||||
|
||||
Assert.Single(meshes.ReferenceCounts);
|
||||
Assert.Equal(1, meshes.ReferenceCounts[residualOverlap]);
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, residualOverlap));
|
||||
Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, residualOverlap));
|
||||
Assert.True(adapter.IsLandblockRenderReady(replacement.LandblockId));
|
||||
}
|
||||
|
||||
private static LoadedLandblock MakeLandblock(uint landblockId, params uint[] gfxObjIds)
|
||||
{
|
||||
WorldEntity[] entities = gfxObjIds.Length == 0
|
||||
? Array.Empty<WorldEntity>()
|
||||
:
|
||||
[
|
||||
new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = gfxObjIds[0],
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = gfxObjIds
|
||||
.Select(id => new MeshRef(id, Matrix4x4.Identity))
|
||||
.ToArray(),
|
||||
},
|
||||
];
|
||||
return new LoadedLandblock(landblockId, new LandBlock(), entities);
|
||||
}
|
||||
|
||||
private enum ReferenceOperation
|
||||
{
|
||||
Increment,
|
||||
PinPrepared,
|
||||
Decrement,
|
||||
}
|
||||
|
||||
private sealed class FaultInjectingMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
private readonly Dictionary<(ReferenceOperation Operation, ulong Id), Queue<bool>> _failures = new();
|
||||
private readonly List<(ReferenceOperation Operation, ulong Id)> _calls = new();
|
||||
|
||||
public Dictionary<ulong, int> ReferenceCounts { get; } = new();
|
||||
|
||||
public void FailNext(ReferenceOperation operation, ulong id, bool committed)
|
||||
{
|
||||
if (!_failures.TryGetValue((operation, id), out Queue<bool>? failures))
|
||||
{
|
||||
failures = new Queue<bool>();
|
||||
_failures.Add((operation, id), failures);
|
||||
}
|
||||
failures.Enqueue(committed);
|
||||
}
|
||||
|
||||
public int CallCount(ReferenceOperation operation, ulong id)
|
||||
=> _calls.Count(call => call == (operation, id));
|
||||
|
||||
public void IncrementRefCount(ulong id)
|
||||
=> Mutate(ReferenceOperation.Increment, id, delta: 1);
|
||||
|
||||
public void PinPreparedRenderData(ulong id)
|
||||
=> Mutate(ReferenceOperation.PinPrepared, id, delta: 1);
|
||||
|
||||
public void DecrementRefCount(ulong id)
|
||||
=> Mutate(ReferenceOperation.Decrement, id, delta: -1);
|
||||
|
||||
public bool IsRenderDataReady(ulong id) => ReferenceCounts.ContainsKey(id);
|
||||
|
||||
private void Mutate(ReferenceOperation operation, ulong id, int delta)
|
||||
{
|
||||
_calls.Add((operation, id));
|
||||
if (_failures.TryGetValue((operation, id), out Queue<bool>? failures)
|
||||
&& failures.TryDequeue(out bool committed))
|
||||
{
|
||||
if (committed)
|
||||
ApplyDelta(id, delta);
|
||||
if (failures.Count == 0)
|
||||
_failures.Remove((operation, id));
|
||||
|
||||
var inner = new InvalidOperationException("Injected mesh-reference failure.");
|
||||
if (committed)
|
||||
{
|
||||
throw new MeshReferenceMutationException(
|
||||
"Injected committed mesh-reference failure.",
|
||||
mutationCommitted: true,
|
||||
inner);
|
||||
}
|
||||
throw inner;
|
||||
}
|
||||
|
||||
ApplyDelta(id, delta);
|
||||
}
|
||||
|
||||
private void ApplyDelta(ulong id, int delta)
|
||||
{
|
||||
int next = ReferenceCounts.GetValueOrDefault(id) + delta;
|
||||
if (next < 0)
|
||||
throw new InvalidOperationException($"Reference underflow for 0x{id:X}.");
|
||||
if (next == 0)
|
||||
ReferenceCounts.Remove(id);
|
||||
else
|
||||
ReferenceCounts[id] = next;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
|
||||
{
|
||||
public HashSet<ulong> ReadyIds { get; } = new();
|
||||
|
|
|
|||
|
|
@ -21,17 +21,17 @@ public sealed class MeshUploadCachesTests
|
|||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var initial));
|
||||
Assert.Same(data, initial);
|
||||
staging.Complete(data.ObjectId); // GPU upload completed, then was later evicted.
|
||||
Assert.Same(data, initial.Data);
|
||||
staging.Complete(initial); // GPU upload completed, then was later evicted.
|
||||
|
||||
data.UploadAttempts = 3;
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached));
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out var cached, out _));
|
||||
Assert.Same(data, cached);
|
||||
Assert.Equal(0, data.UploadAttempts);
|
||||
Assert.Equal(textureBytes, cached!.TextureBatches.Values.Single().Single().TextureData);
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _));
|
||||
Assert.True(cache.TryGetAndStage(data.ObjectId, staging, out _, out _));
|
||||
Assert.True(staging.TryDequeue(out var restaged));
|
||||
Assert.Same(data, restaged);
|
||||
Assert.Same(data, restaged.Data);
|
||||
Assert.False(staging.TryDequeue(out _));
|
||||
}
|
||||
|
||||
|
|
@ -43,12 +43,12 @@ public sealed class MeshUploadCachesTests
|
|||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var attempt));
|
||||
staging.Requeue(attempt!);
|
||||
staging.Requeue(attempt);
|
||||
Assert.False(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out var retry));
|
||||
Assert.Same(data, retry);
|
||||
Assert.Same(data, retry.Data);
|
||||
|
||||
staging.Complete(data.ObjectId);
|
||||
staging.Complete(retry);
|
||||
Assert.True(staging.Stage(data));
|
||||
}
|
||||
|
||||
|
|
@ -66,4 +66,244 @@ public sealed class MeshUploadCachesTests
|
|||
Assert.False(ownership.MarkUploadComplete(id)); // late upload after unload
|
||||
Assert.Equal(0, ownership.Count(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StagingQueueRejectsProducerWorkAboveByteHighWater()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64);
|
||||
ObjectMeshData first = DataWithTexture(1, 40);
|
||||
ObjectMeshData second = DataWithTexture(2, 40);
|
||||
|
||||
Assert.True(staging.Stage(first));
|
||||
Assert.False(staging.Stage(second));
|
||||
Assert.Equal(1, staging.Count);
|
||||
Assert.Equal(40, staging.QueuedBytes);
|
||||
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem firstItem));
|
||||
staging.Complete(firstItem);
|
||||
Assert.True(staging.Stage(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuCacheHitReportsHighWaterWithoutPretendingItWasStaged()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(maximumCount: 8, maximumBytes: 64);
|
||||
var cache = new CpuMeshUploadCache(capacity: 2);
|
||||
ObjectMeshData queued = DataWithTexture(1, 40);
|
||||
ObjectMeshData cached = DataWithTexture(2, 40);
|
||||
cache.Store(cached);
|
||||
Assert.True(staging.Stage(queued));
|
||||
|
||||
Assert.True(cache.TryGetAndStage(2, staging, out ObjectMeshData? found, out MeshStageResult result));
|
||||
Assert.Same(cached, found);
|
||||
Assert.Equal(MeshStageResult.HighWater, result);
|
||||
Assert.Equal(1, staging.Count);
|
||||
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem queuedItem));
|
||||
staging.Complete(queuedItem);
|
||||
Assert.True(cache.TryGetAndStage(2, staging, out _, out result));
|
||||
Assert.Equal(MeshStageResult.Staged, result);
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem staged));
|
||||
Assert.Same(cached, staged.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StalePrefixDrainIsBoundedAndDoesNotConsumeOwnedHead()
|
||||
{
|
||||
const int total = 1000;
|
||||
var staging = new MeshUploadStagingQueue(
|
||||
maximumCount: total + 1,
|
||||
maximumBytes: long.MaxValue);
|
||||
var ownership = new MeshOwnershipCounter();
|
||||
for (ulong id = 1; id <= total; id++)
|
||||
Assert.True(staging.Stage(new ObjectMeshData { ObjectId = id }));
|
||||
ownership.Acquire(total);
|
||||
|
||||
Assert.Equal(64, staging.DiscardUnownedPrefix(ownership, maximum: 64));
|
||||
Assert.Equal(total - 64, staging.Count);
|
||||
|
||||
Assert.Equal(total - 65, staging.DiscardUnownedPrefix(ownership, maximum: total));
|
||||
Assert.True(staging.TryPeek(out MeshUploadQueueItem owned));
|
||||
Assert.Equal((ulong)total, owned.Data.ObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuCacheRearmsMeshAfterStaleStageWasDiscarded()
|
||||
{
|
||||
const ulong id = 0x01000010;
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var cache = new CpuMeshUploadCache(capacity: 2);
|
||||
var ownership = new MeshOwnershipCounter();
|
||||
var data = DataWithTexture(id, 16);
|
||||
cache.Store(data);
|
||||
Assert.True(staging.Stage(data));
|
||||
|
||||
Assert.Equal(1, staging.DiscardUnownedPrefix(ownership, maximum: 1));
|
||||
Assert.Equal(0, staging.Count);
|
||||
|
||||
ownership.Acquire(id);
|
||||
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? rearmed, out _));
|
||||
Assert.Same(data, rearmed);
|
||||
Assert.Equal(0, staging.DiscardUnownedPrefix(ownership, maximum: 1));
|
||||
Assert.True(staging.TryPeek(out MeshUploadQueueItem queued));
|
||||
Assert.Same(data, queued.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuCacheUsesByteBudgetInAdditionToEntryCount()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var cache = new CpuMeshUploadCache(capacity: 10, byteCapacity: 64);
|
||||
cache.Store(DataWithTexture(1, 40));
|
||||
cache.Store(DataWithTexture(2, 40));
|
||||
|
||||
Assert.False(cache.TryGetAndStage(1, staging, out _, out _));
|
||||
Assert.True(cache.TryGetAndStage(2, staging, out _, out _));
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(40, cache.ResidentBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CpuCacheRejectsOversizedEntryBeforeEvictingOrPublishingIt()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
|
||||
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
|
||||
ObjectMeshData retained = DataWithTexture(1, 40);
|
||||
ObjectMeshData oversized = DataWithTexture(2, 80);
|
||||
|
||||
Assert.True(cache.Store(retained));
|
||||
Assert.False(cache.Store(oversized));
|
||||
|
||||
Assert.Equal(1, cache.Count);
|
||||
Assert.Equal(40, cache.ResidentBytes);
|
||||
Assert.False(cache.TryGetAndStage(2, staging, out _, out _));
|
||||
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
|
||||
Assert.Same(retained, found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedReplacementCannotDisplaceBoundedCachedGeneration()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(maximumBytes: 256);
|
||||
var cache = new CpuMeshUploadCache(capacity: 2, byteCapacity: 64);
|
||||
ObjectMeshData retained = DataWithTexture(1, 40);
|
||||
|
||||
Assert.True(cache.Store(retained));
|
||||
Assert.False(cache.Store(DataWithTexture(1, 80)));
|
||||
Assert.True(cache.TryGetAndStage(1, staging, out ObjectMeshData? found, out _));
|
||||
Assert.Same(retained, found);
|
||||
Assert.Equal(40, cache.ResidentBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DequeuedUnownedGenerationHandsOffToRacingCacheHit()
|
||||
{
|
||||
const ulong id = 0x01000010;
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var cache = new CpuMeshUploadCache(capacity: 2);
|
||||
var ownership = new MeshOwnershipCounter();
|
||||
ObjectMeshData data = DataWithTexture(id, 16);
|
||||
cache.Store(data);
|
||||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued));
|
||||
|
||||
// The new owner cache-hits while the dequeued generation still owns
|
||||
// staging's id claim, so its direct Stage attempt cannot enqueue yet.
|
||||
ownership.Acquire(id);
|
||||
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
|
||||
Assert.Same(data, cached);
|
||||
Assert.Equal(0, staging.Count);
|
||||
|
||||
Assert.True(staging.CompleteOrRestageIfOwned(dequeued, ownership));
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff));
|
||||
Assert.Same(data, handedOff.Data);
|
||||
Assert.False(staging.TryDequeue(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnerArrivingAfterUnownedCompletionStagesCachedPayloadExactlyOnce()
|
||||
{
|
||||
const ulong id = 0x01000010;
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var cache = new CpuMeshUploadCache(capacity: 2);
|
||||
var ownership = new MeshOwnershipCounter();
|
||||
ObjectMeshData data = DataWithTexture(id, 16);
|
||||
cache.Store(data);
|
||||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem dequeued));
|
||||
Assert.False(staging.CompleteOrRestageIfOwned(dequeued, ownership));
|
||||
Assert.Equal(0, staging.Count);
|
||||
|
||||
ownership.Acquire(id);
|
||||
Assert.True(cache.TryGetAndStage(id, staging, out ObjectMeshData? cached, out _));
|
||||
Assert.Same(data, cached);
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem handedOff));
|
||||
Assert.Same(data, handedOff.Data);
|
||||
Assert.False(staging.TryDequeue(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReacquiredObjectGetsNewImmutableQueueGeneration()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue();
|
||||
var data = new ObjectMeshData { ObjectId = 0x01000010 };
|
||||
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem first));
|
||||
staging.Complete(first);
|
||||
Assert.True(staging.Stage(data));
|
||||
Assert.True(staging.TryPeek(out MeshUploadQueueItem replacement));
|
||||
|
||||
Assert.NotEqual(first.Generation, replacement.Generation);
|
||||
Assert.Throws<InvalidOperationException>(() => staging.Complete(first));
|
||||
Assert.True(staging.TryPeek(out MeshUploadQueueItem stillQueued));
|
||||
Assert.Equal(replacement.Generation, stillQueued.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedHeadIsExplicitlyBoundedAndFollowingProducerBackpressures()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(
|
||||
maximumCount: 8,
|
||||
maximumBytes: 64,
|
||||
maximumSingleEntryBytes: 96);
|
||||
ObjectMeshData oversized = DataWithTexture(1, 80);
|
||||
|
||||
Assert.True(staging.Stage(oversized));
|
||||
Assert.False(staging.Stage(DataWithTexture(2, 1)));
|
||||
Assert.Throws<NotSupportedException>(() => staging.Stage(DataWithTexture(3, 97)));
|
||||
Assert.Equal(1, staging.Count);
|
||||
Assert.Equal(80, staging.QueuedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DequeuedGenerationStillCountsAgainstProducerByteAndCountBounds()
|
||||
{
|
||||
var staging = new MeshUploadStagingQueue(maximumCount: 1, maximumBytes: 64);
|
||||
ObjectMeshData first = DataWithTexture(1, 40);
|
||||
|
||||
Assert.True(staging.Stage(first));
|
||||
Assert.True(staging.TryDequeue(out MeshUploadQueueItem inFlight));
|
||||
Assert.Equal(0, staging.Count);
|
||||
Assert.Equal(1, staging.ClaimCount);
|
||||
Assert.Equal(0, staging.QueuedBytes);
|
||||
Assert.Equal(40, staging.ClaimedBytes);
|
||||
Assert.True(staging.IsAtHighWater);
|
||||
Assert.False(staging.Stage(DataWithTexture(2, 1)));
|
||||
|
||||
staging.Complete(inFlight);
|
||||
Assert.True(staging.Stage(DataWithTexture(2, 1)));
|
||||
}
|
||||
|
||||
private static ObjectMeshData DataWithTexture(ulong id, int bytes)
|
||||
{
|
||||
var data = new ObjectMeshData { ObjectId = id };
|
||||
data.TextureBatches[(1, 1, TextureFormat.RGBA8)] =
|
||||
[
|
||||
new TextureBatchData { TextureData = new byte[bytes] },
|
||||
];
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Content;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class ObjectMeshUploadBudgetTests
|
||||
{
|
||||
[Fact]
|
||||
public void EstimateIncludesVerticesIndicesTexturesAndNestedGeometry()
|
||||
{
|
||||
var nested = new ObjectMeshData
|
||||
{
|
||||
Vertices = new VertexPositionNormalTexture[2],
|
||||
TextureBatches =
|
||||
{
|
||||
[(8, 8, TextureFormat.RGBA8)] =
|
||||
[
|
||||
new TextureBatchData
|
||||
{
|
||||
TextureData = new byte[256],
|
||||
Indices = [0, 1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
var setup = new ObjectMeshData
|
||||
{
|
||||
IsSetup = true,
|
||||
EnvCellGeometry = nested,
|
||||
};
|
||||
|
||||
long expected = 1024
|
||||
+ 2L * VertexPositionNormalTexture.Size
|
||||
+ 256
|
||||
+ 3L * sizeof(ushort);
|
||||
Assert.Equal(expected, ObjectMeshManager.EstimateUploadBytes(setup));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First512RgbaLayerIncludesArrayAndWholeMipChain()
|
||||
{
|
||||
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
|
||||
512,
|
||||
512,
|
||||
TextureFormat.RGBA8,
|
||||
uploadBytes: 512 * 512 * 4,
|
||||
sourceBytes: 512 * 512 * 4);
|
||||
|
||||
long expectedArray = TextureAtlasManager.CalculateArrayBytes(512, 512, TextureFormat.RGBA8);
|
||||
Assert.Equal(expectedArray, cost.ArrayAllocationBytes);
|
||||
Assert.Equal(expectedArray, cost.MipmapBytes);
|
||||
Assert.Equal(1, cost.NewArrayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First1024RgbaLayerUsesOneLayerAtlasCapacity()
|
||||
{
|
||||
const int layerBytes = 1024 * 1024 * 4;
|
||||
|
||||
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
|
||||
1024,
|
||||
1024,
|
||||
TextureFormat.RGBA8,
|
||||
uploadBytes: layerBytes,
|
||||
sourceBytes: layerBytes);
|
||||
|
||||
Assert.Equal(1, TextureAtlasManager.CalculateInitialCapacity(1024, 1024, TextureFormat.RGBA8));
|
||||
Assert.Equal(5_592_404, cost.ArrayAllocationBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First512RgbaLayerHasFixedGoldenPhysicalCosts()
|
||||
{
|
||||
const int layerBytes = 512 * 512 * 4;
|
||||
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasFirstUploadCost(
|
||||
512,
|
||||
512,
|
||||
TextureFormat.RGBA8,
|
||||
uploadBytes: layerBytes);
|
||||
|
||||
Assert.Equal(6, TextureAtlasManager.CalculateInitialCapacity(512, 512, TextureFormat.RGBA8));
|
||||
Assert.Equal(8_388_600, cost.ArrayAllocationBytes);
|
||||
Assert.Equal(8_388_600, cost.MipmapBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Five512RgbaLayersChargeOneArrayAndOneMipGeneration()
|
||||
{
|
||||
const int layerBytes = 512 * 512 * 4;
|
||||
|
||||
MeshUploadCost cost = ObjectMeshManager.CalculateNewAtlasUploadCost(
|
||||
512,
|
||||
512,
|
||||
TextureFormat.RGBA8,
|
||||
[layerBytes, layerBytes, layerBytes, layerBytes, layerBytes]);
|
||||
|
||||
Assert.Equal(8_388_600, cost.ArrayAllocationBytes);
|
||||
Assert.Equal(8_388_600, cost.MipmapBytes);
|
||||
Assert.Equal(1, cost.NewArrayCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundedIndivisibleHeadRunsImmediatelyWithoutVirtualReservation()
|
||||
{
|
||||
var limits = new MeshUploadBudgetLimits(
|
||||
MaximumObjects: 4,
|
||||
MaximumSourceBytes: 100,
|
||||
MaximumArrayAllocationBytes: 100,
|
||||
MaximumMipmapBytes: 100,
|
||||
MaximumNewArrays: 2,
|
||||
MaximumSingleSourceBytes: 200,
|
||||
MaximumSingleArrayAllocationBytes: 200,
|
||||
MaximumSingleMipmapBytes: 200,
|
||||
MaximumSingleNewArrays: 4);
|
||||
var budget = new MeshUploadFrameBudget(limits);
|
||||
|
||||
var oversized = new MeshUploadCost(150, 150, 150, 3);
|
||||
Assert.True(budget.TryAdmit(oversized));
|
||||
Assert.False(budget.TryAdmit(new MeshUploadCost(1, 0, 0, 0)));
|
||||
Assert.Equal(1, budget.ObjectCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeadAboveExplicitSingleOperationCeilingFailsLoudly()
|
||||
{
|
||||
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
|
||||
MaximumObjects: 4,
|
||||
MaximumSourceBytes: 100,
|
||||
MaximumArrayAllocationBytes: 100,
|
||||
MaximumMipmapBytes: 100,
|
||||
MaximumNewArrays: 1,
|
||||
MaximumSingleSourceBytes: 200));
|
||||
|
||||
Assert.Throws<NotSupportedException>(
|
||||
() => budget.TryAdmit(new MeshUploadCost(250, 0, 0, 0, AdmissionKey: 17)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BufferMigrationCostCannotBePretendedAdmitted()
|
||||
{
|
||||
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
|
||||
MaximumObjects: 4,
|
||||
MaximumSourceBytes: 100,
|
||||
MaximumArrayAllocationBytes: 100,
|
||||
MaximumMipmapBytes: 100,
|
||||
MaximumNewArrays: 1));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => budget.TryAdmit(new MeshUploadCost(
|
||||
1, 0, 0, 0,
|
||||
BufferAllocationBytes: 1,
|
||||
BufferCopyBytes: 1,
|
||||
NewBufferCount: 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalArenaGrowthIsGeometricAndAvoidsRepeatedPrefixCopies()
|
||||
{
|
||||
Assert.Equal(
|
||||
1_500,
|
||||
GlobalMeshBuffer.CalculateGrowthCapacity(
|
||||
capacity: 1_000,
|
||||
trailingFreeLength: 100,
|
||||
requiredContiguousLength: 300,
|
||||
growthQuantum: 50));
|
||||
|
||||
Assert.Equal(
|
||||
1_000,
|
||||
GlobalMeshBuffer.CalculateGrowthCapacity(
|
||||
capacity: 1_000,
|
||||
trailingFreeLength: 300,
|
||||
requiredContiguousLength: 300,
|
||||
growthQuantum: 50));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalArenaTrimUsesThreeToOneHysteresisAndKeepsDoubleHeadroom()
|
||||
{
|
||||
Assert.True(GlobalMeshBuffer.TryCalculateTrimCapacity(
|
||||
capacity: 8_192,
|
||||
highWaterMark: 1_000,
|
||||
initialCapacity: 512,
|
||||
growthQuantum: 256,
|
||||
out int trimmed));
|
||||
Assert.Equal(2_048, trimmed);
|
||||
|
||||
Assert.False(GlobalMeshBuffer.TryCalculateTrimCapacity(
|
||||
capacity: 2_048,
|
||||
highWaterMark: 1_200,
|
||||
initialCapacity: 512,
|
||||
growthQuantum: 256,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FrameBudgetRejectsPrefixItemWhenAnySingleDimensionWouldOverflow()
|
||||
{
|
||||
var budget = new MeshUploadFrameBudget(new MeshUploadBudgetLimits(
|
||||
MaximumObjects: 4,
|
||||
MaximumSourceBytes: 100,
|
||||
MaximumArrayAllocationBytes: 100,
|
||||
MaximumMipmapBytes: 100,
|
||||
MaximumNewArrays: 2,
|
||||
MaximumBufferUploadBytes: 100,
|
||||
MaximumBufferAllocationBytes: 100,
|
||||
MaximumBufferCopyBytes: 100,
|
||||
MaximumNewBuffers: 2));
|
||||
|
||||
Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40)));
|
||||
Assert.True(budget.TryAdmit(new MeshUploadCost(40, 40, 40, 1, BufferUploadBytes: 40)));
|
||||
Assert.False(budget.TryAdmit(new MeshUploadCost(1, 1, 1, 0, BufferUploadBytes: 21)));
|
||||
Assert.Equal(2, budget.ObjectCount);
|
||||
Assert.Equal(80, budget.BufferUploadBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationCopyChunksNeverExceedRealFrameBudget()
|
||||
{
|
||||
const long budget = 32L * 1024 * 1024;
|
||||
const long total = 100L * 1024 * 1024;
|
||||
|
||||
Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, 0, budget));
|
||||
Assert.Equal(budget, GlobalMeshBuffer.CalculateCopyChunk(total, budget, budget));
|
||||
Assert.Equal(4L * 1024 * 1024,
|
||||
GlobalMeshBuffer.CalculateCopyChunk(total, 96L * 1024 * 1024, budget));
|
||||
Assert.Equal(0, GlobalMeshBuffer.CalculateCopyChunk(total, total, budget));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModernArenaGeometryIsCountedOnlyByPhysicalBackingCapacity()
|
||||
{
|
||||
const long arenaCapacity = 512;
|
||||
const long geometryRanges = 100;
|
||||
|
||||
long modernNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes(
|
||||
usesGlobalArena: true,
|
||||
geometryRanges);
|
||||
long legacyNonArena = ObjectMeshManager.CalculateNonArenaGeometryBytes(
|
||||
usesGlobalArena: false,
|
||||
geometryRanges);
|
||||
|
||||
Assert.Equal(0, modernNonArena);
|
||||
Assert.Equal(arenaCapacity,
|
||||
ObjectMeshManager.CalculateTrackedGpuBytes(modernNonArena, arenaCapacity));
|
||||
Assert.Equal(arenaCapacity + geometryRanges,
|
||||
ObjectMeshManager.CalculateTrackedGpuBytes(legacyNonArena, arenaCapacity));
|
||||
Assert.True(ObjectMeshManager.IsWithinGpuCacheBudget(0, arenaCapacity, 600));
|
||||
Assert.False(ObjectMeshManager.IsWithinGpuCacheBudget(100, arenaCapacity, 600));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetupDependencyRollbackAttemptsEveryPartAndRetriesOnlyFailure()
|
||||
{
|
||||
var calls = new Dictionary<ulong, int>();
|
||||
RetryableResourceReleaseLedger rollback =
|
||||
ObjectMeshManager.CreateSetupPartRollback(
|
||||
[1, 2, 3],
|
||||
id =>
|
||||
{
|
||||
calls[id] = calls.GetValueOrDefault(id) + 1;
|
||||
if (id == 2 && calls[id] == 1)
|
||||
throw new InvalidOperationException("part release");
|
||||
});
|
||||
|
||||
ResourceReleaseAttempt first = rollback.Advance();
|
||||
Assert.Single(first.Failures);
|
||||
Assert.Equal(1, calls[1]);
|
||||
Assert.Equal(1, calls[2]);
|
||||
Assert.Equal(1, calls[3]);
|
||||
|
||||
ResourceReleaseAttempt retry = rollback.Advance();
|
||||
Assert.Empty(retry.Failures);
|
||||
Assert.True(rollback.IsComplete);
|
||||
Assert.Equal(1, calls[1]);
|
||||
Assert.Equal(2, calls[2]);
|
||||
Assert.Equal(1, calls[3]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReclamationBudgetCanSkipOversizedHeadAndAdmitSmallerCandidate()
|
||||
{
|
||||
Assert.False(ObjectMeshManager.FitsReclamationBudget(
|
||||
candidateBytes: 80,
|
||||
alreadyReclaimedBytes: 0,
|
||||
maximumBytes: 64));
|
||||
Assert.True(ObjectMeshManager.FitsReclamationBudget(
|
||||
candidateBytes: 16,
|
||||
alreadyReclaimedBytes: 0,
|
||||
maximumBytes: 64));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class RetryableResourceReleaseLedgerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Advance_AttemptsEveryStage_AndRetriesOnlyUnfinishedStage()
|
||||
{
|
||||
int firstCalls = 0;
|
||||
int flakyCalls = 0;
|
||||
int lastCalls = 0;
|
||||
var ledger = new RetryableResourceReleaseLedger(
|
||||
[
|
||||
("first", () => firstCalls++),
|
||||
("flaky", () =>
|
||||
{
|
||||
flakyCalls++;
|
||||
if (flakyCalls == 1)
|
||||
throw new InvalidOperationException("injected pre-commit failure");
|
||||
}),
|
||||
("last", () => lastCalls++),
|
||||
]);
|
||||
|
||||
ResourceReleaseAttempt first = ledger.Advance();
|
||||
|
||||
Assert.False(ledger.IsComplete);
|
||||
Assert.Equal(1, ledger.RemainingCount);
|
||||
Assert.Equal(3, first.AttemptedCount);
|
||||
Assert.Equal(2, first.CompletedCount);
|
||||
Assert.Single(first.Failures);
|
||||
Assert.False(first.Failures[0].MutationCommitted);
|
||||
Assert.Equal(1, firstCalls);
|
||||
Assert.Equal(1, flakyCalls);
|
||||
Assert.Equal(1, lastCalls);
|
||||
|
||||
ResourceReleaseAttempt retry = ledger.Advance();
|
||||
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.Equal(0, ledger.RemainingCount);
|
||||
Assert.Equal(1, retry.AttemptedCount);
|
||||
Assert.Equal(1, retry.CompletedCount);
|
||||
Assert.Empty(retry.Failures);
|
||||
Assert.Equal(1, firstCalls);
|
||||
Assert.Equal(2, flakyCalls);
|
||||
Assert.Equal(1, lastCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_CommittedExceptionalMutation_IsRecordedButNeverReplayed()
|
||||
{
|
||||
int committedCalls = 0;
|
||||
int laterCalls = 0;
|
||||
var ledger = new RetryableResourceReleaseLedger(
|
||||
[
|
||||
("committed", () =>
|
||||
{
|
||||
committedCalls++;
|
||||
throw new MeshReferenceMutationException(
|
||||
"injected post-commit failure",
|
||||
mutationCommitted: true,
|
||||
new InvalidOperationException("observer failed"));
|
||||
}),
|
||||
("later", () => laterCalls++),
|
||||
]);
|
||||
|
||||
ResourceReleaseAttempt attempt = ledger.Advance();
|
||||
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.Equal(2, attempt.AttemptedCount);
|
||||
Assert.Equal(2, attempt.CompletedCount);
|
||||
ResourceReleaseFailure failure = Assert.Single(attempt.Failures);
|
||||
Assert.Equal("committed", failure.Stage);
|
||||
Assert.True(failure.MutationCommitted);
|
||||
Assert.Equal(1, committedCalls);
|
||||
Assert.Equal(1, laterCalls);
|
||||
|
||||
ResourceReleaseAttempt duplicate = ledger.Advance();
|
||||
|
||||
Assert.Equal(0, duplicate.AttemptedCount);
|
||||
Assert.Empty(duplicate.Failures);
|
||||
Assert.Equal(1, committedCalls);
|
||||
Assert.Equal(1, laterCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_MultipleIndependentFailures_AllRunAndConvergeIndependently()
|
||||
{
|
||||
int firstFailures = 0;
|
||||
int secondFailures = 0;
|
||||
int successfulTail = 0;
|
||||
var ledger = new RetryableResourceReleaseLedger(
|
||||
[
|
||||
("first", () =>
|
||||
{
|
||||
if (firstFailures++ == 0)
|
||||
throw new InvalidOperationException("first");
|
||||
}),
|
||||
("second", () =>
|
||||
{
|
||||
if (secondFailures++ < 2)
|
||||
throw new InvalidOperationException("second");
|
||||
}),
|
||||
("tail", () => successfulTail++),
|
||||
]);
|
||||
|
||||
ResourceReleaseAttempt first = ledger.Advance();
|
||||
ResourceReleaseAttempt second = ledger.Advance();
|
||||
ResourceReleaseAttempt third = ledger.Advance();
|
||||
|
||||
Assert.Equal(2, first.Failures.Count);
|
||||
Assert.Single(second.Failures);
|
||||
Assert.Empty(third.Failures);
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.Equal(1, successfulTail);
|
||||
Assert.Equal(2, firstFailures);
|
||||
Assert.Equal(3, secondFailures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_ReentrantAttempt_DoesNotExecuteActiveStageTwice()
|
||||
{
|
||||
RetryableResourceReleaseLedger? ledger = null;
|
||||
int activeCalls = 0;
|
||||
int tailCalls = 0;
|
||||
ledger = new RetryableResourceReleaseLedger(
|
||||
[
|
||||
("active", () =>
|
||||
{
|
||||
activeCalls++;
|
||||
ledger!.Advance();
|
||||
}),
|
||||
("tail", () => tailCalls++),
|
||||
]);
|
||||
|
||||
ResourceReleaseAttempt attempt = ledger.Advance();
|
||||
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.Empty(attempt.Failures);
|
||||
Assert.Equal(1, activeCalls);
|
||||
Assert.Equal(1, tailCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_ReentrantAttemptDoesNotGiveFailedTailTwoAttemptsInOnePass()
|
||||
{
|
||||
RetryableResourceReleaseLedger? ledger = null;
|
||||
int activeCalls = 0;
|
||||
int flakyTailCalls = 0;
|
||||
ledger = new RetryableResourceReleaseLedger(
|
||||
[
|
||||
("active", () =>
|
||||
{
|
||||
activeCalls++;
|
||||
ledger!.Advance();
|
||||
}),
|
||||
("flaky-tail", () =>
|
||||
{
|
||||
flakyTailCalls++;
|
||||
if (flakyTailCalls == 1)
|
||||
throw new InvalidOperationException("first pass");
|
||||
}),
|
||||
]);
|
||||
|
||||
ResourceReleaseAttempt first = ledger.Advance();
|
||||
|
||||
Assert.Single(first.Failures);
|
||||
Assert.Equal(1, activeCalls);
|
||||
Assert.Equal(1, flakyTailCalls);
|
||||
Assert.False(ledger.IsComplete);
|
||||
|
||||
ResourceReleaseAttempt second = ledger.Advance();
|
||||
Assert.Empty(second.Failures);
|
||||
Assert.True(ledger.IsComplete);
|
||||
Assert.Equal(2, flakyTailCalls);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class TextureAtlasCapacityTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(128, 128, 32)]
|
||||
[InlineData(256, 256, 24)]
|
||||
[InlineData(512, 512, 6)]
|
||||
[InlineData(1024, 1024, 1)]
|
||||
public void RgbaCapacityTargetsEightMiBIncludingMipChain(
|
||||
int width,
|
||||
int height,
|
||||
int expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
TextureAtlasManager.CalculateInitialCapacity(width, height, TextureFormat.RGBA8));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallCompressedLayersRemainCountCapped()
|
||||
{
|
||||
Assert.Equal(
|
||||
TextureAtlasManager.MaximumArrayLayers,
|
||||
TextureAtlasManager.CalculateInitialCapacity(32, 32, TextureFormat.DXT1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectUploadAcceptsExactRgbaPayload()
|
||||
{
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.UnsignedByte);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(15)]
|
||||
[InlineData(17)]
|
||||
public void DirectUploadRejectsNonExactPayloadLength(int bytes)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectUploadRejectsMismatchedTransferTuple()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgb, PixelType.UnsignedByte));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGBA8, 2, 2, 16, PixelFormat.Rgba, PixelType.Float));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompressedUploadRejectsUncompressedDescriptorOverride()
|
||||
{
|
||||
int bytes = ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.DXT1, 4, 4);
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.DXT1, 4, 4, bytes, PixelFormat.Rgba, PixelType.UnsignedByte));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RgbPayloadUsesTightlyPackedRows()
|
||||
{
|
||||
Assert.Equal(18, ManagedGLTextureArray.CalculateExpectedDataSize(TextureFormat.RGB8, 3, 2));
|
||||
ManagedGLTextureArray.ValidateUploadPayload(
|
||||
TextureFormat.RGB8, 3, 2, 18, PixelFormat.Rgb, PixelType.UnsignedByte);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class TextureAtlasSlotAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SlotIsNotAvailableUntilGpuRetirementReturnsIt()
|
||||
{
|
||||
var slots = new TextureAtlasSlotAllocator(capacity: 2);
|
||||
int first = slots.Rent();
|
||||
int second = slots.Rent();
|
||||
|
||||
Assert.Equal(0, slots.AvailableCount);
|
||||
Assert.Throws<InvalidOperationException>(() => slots.Rent());
|
||||
|
||||
// Dropping the texture's CPU key does not call Return. The layer is
|
||||
// still unavailable while an older draw may be sampling it.
|
||||
Assert.Equal(0, slots.AvailableCount);
|
||||
|
||||
slots.Return(first);
|
||||
|
||||
Assert.Equal(1, slots.AvailableCount);
|
||||
Assert.Equal(first, slots.Rent());
|
||||
Assert.Equal(0, slots.AvailableCount);
|
||||
Assert.NotEqual(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoubleReturnIsRejected()
|
||||
{
|
||||
var slots = new TextureAtlasSlotAllocator(capacity: 1);
|
||||
int slot = slots.Rent();
|
||||
slots.Return(slot);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => slots.Return(slot));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LayerRetirement_PublicationFailureRetainsPhysicalReturn()
|
||||
{
|
||||
var queue = new DeferredQueue { FailNextPublication = true };
|
||||
var retirement = new TextureAtlasLayerRetirement(queue);
|
||||
int returns = 0;
|
||||
int notifications = 0;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
retirement.Retire(() => returns++, () => notifications++));
|
||||
Assert.Equal(1, retirement.AwaitingPublicationCount);
|
||||
Assert.Equal(0, returns);
|
||||
|
||||
retirement.RetryPendingPublications();
|
||||
Assert.Equal(0, retirement.AwaitingPublicationCount);
|
||||
queue.Actions.Single()();
|
||||
Assert.Equal(1, returns);
|
||||
Assert.Equal(1, notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LayerRetirement_ObserverFailureNeverReturnsSlotTwice()
|
||||
{
|
||||
var queue = new DeferredQueue();
|
||||
var retirement = new TextureAtlasLayerRetirement(queue);
|
||||
int returns = 0;
|
||||
int notifications = 0;
|
||||
retirement.Retire(
|
||||
() => returns++,
|
||||
() =>
|
||||
{
|
||||
notifications++;
|
||||
if (notifications == 1)
|
||||
throw new InvalidOperationException("observer");
|
||||
});
|
||||
Action callback = queue.Actions.Single();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(callback);
|
||||
callback();
|
||||
|
||||
Assert.Equal(1, returns);
|
||||
Assert.Equal(2, notifications);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeTransaction_ArrayPublicationFailureRetainsRetryableOwnership()
|
||||
{
|
||||
var transaction = new TextureAtlasDisposeTransaction();
|
||||
int layerRetries = 0;
|
||||
int arrayDisposals = 0;
|
||||
int logicalCommits = 0;
|
||||
bool failFirstArrayDisposal = true;
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => transaction.Advance(
|
||||
() => layerRetries++,
|
||||
() => {
|
||||
arrayDisposals++;
|
||||
if (failFirstArrayDisposal) {
|
||||
failFirstArrayDisposal = false;
|
||||
throw new InvalidOperationException("injected array publication failure");
|
||||
}
|
||||
},
|
||||
() => logicalCommits++));
|
||||
|
||||
Assert.False(transaction.IsComplete);
|
||||
Assert.Equal(1, layerRetries);
|
||||
Assert.Equal(1, arrayDisposals);
|
||||
Assert.Equal(0, logicalCommits);
|
||||
|
||||
transaction.Advance(
|
||||
() => layerRetries++,
|
||||
() => arrayDisposals++,
|
||||
() => logicalCommits++);
|
||||
transaction.Advance(
|
||||
() => layerRetries++,
|
||||
() => arrayDisposals++,
|
||||
() => logicalCommits++);
|
||||
|
||||
Assert.True(transaction.IsComplete);
|
||||
Assert.Equal(2, layerRetries);
|
||||
Assert.Equal(2, arrayDisposals);
|
||||
Assert.Equal(1, logicalCommits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisposeTransaction_ReentrantAdvanceDoesNotReplayActiveStage()
|
||||
{
|
||||
var transaction = new TextureAtlasDisposeTransaction();
|
||||
int layerRetries = 0;
|
||||
int arrayDisposals = 0;
|
||||
int logicalCommits = 0;
|
||||
Action retryLayers = () => layerRetries++;
|
||||
Action commit = () => logicalCommits++;
|
||||
Action? disposeArray = null;
|
||||
disposeArray = () => {
|
||||
arrayDisposals++;
|
||||
transaction.Advance(retryLayers, disposeArray!, commit);
|
||||
};
|
||||
|
||||
transaction.Advance(retryLayers, disposeArray, commit);
|
||||
|
||||
Assert.True(transaction.IsComplete);
|
||||
Assert.Equal(1, layerRetries);
|
||||
Assert.Equal(1, arrayDisposals);
|
||||
Assert.Equal(1, logicalCommits);
|
||||
}
|
||||
|
||||
private sealed class DeferredQueue : IGpuResourceRetirementQueue
|
||||
{
|
||||
public bool FailNextPublication { get; set; }
|
||||
public List<Action> Actions { get; } = [];
|
||||
|
||||
public void Retire(Action release)
|
||||
{
|
||||
if (FailNextPublication)
|
||||
{
|
||||
FailNextPublication = false;
|
||||
throw new InvalidOperationException("publication");
|
||||
}
|
||||
Actions.Add(release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class WbDrawDispatcherCompositeWarmupTests
|
||||
{
|
||||
private const uint Destination = 0x1234FFFFu;
|
||||
|
||||
[Fact]
|
||||
public void NewlyPublishedEntitySnapshotRequiresRebuildAfterPriorSnapshotWasReady()
|
||||
{
|
||||
IReadOnlyList<WorldEntity> emptyReadySnapshot = Array.Empty<WorldEntity>();
|
||||
IReadOnlyList<WorldEntity> publishedDestinationSnapshot =
|
||||
[CreateEntity(parentCell: Destination, withPalette: true)];
|
||||
|
||||
Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
|
||||
emptyReadySnapshot,
|
||||
Destination,
|
||||
currentRadius: 0,
|
||||
currentGeneration: 0,
|
||||
publishedDestinationSnapshot,
|
||||
nextGeneration: 1,
|
||||
Destination,
|
||||
nextRadius: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MutatedStableEntityViewRequiresRebuildWhenGenerationChanges()
|
||||
{
|
||||
IReadOnlyList<WorldEntity> stableView = new List<WorldEntity>();
|
||||
|
||||
Assert.True(WbDrawDispatcher.RequiresCompositeWarmupRebuild(
|
||||
stableView,
|
||||
Destination,
|
||||
currentRadius: 0,
|
||||
currentGeneration: 41,
|
||||
stableView,
|
||||
nextGeneration: 42,
|
||||
Destination,
|
||||
nextRadius: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaletteEntityInsideDestinationRadiusIsCandidate()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: 0x1235FFFEu, withPalette: true);
|
||||
|
||||
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityOutsideDestinationRadiusIsExcluded()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: 0x1236FFFEu, withPalette: true);
|
||||
|
||||
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellLessEntityIsExcludedFromKnownDestination()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: null, withPalette: true);
|
||||
|
||||
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameLandblockIndoorCellIsCandidateAtZeroRadius()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: 0x1234012Au, withPalette: true);
|
||||
|
||||
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityWithoutPaletteOrSurfaceOverridesIsExcluded()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false);
|
||||
|
||||
Assert.False(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SurfaceOverrideEntityIsCandidateWithoutPalette()
|
||||
{
|
||||
WorldEntity entity = CreateEntity(parentCell: Destination, withPalette: false);
|
||||
entity.MeshRefs =
|
||||
[
|
||||
new MeshRef(0x01000001u, Matrix4x4.Identity)
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint> { [0x08000001u] = 0x05000001u },
|
||||
},
|
||||
];
|
||||
|
||||
Assert.True(WbDrawDispatcher.IsCompositeWarmupCandidate(entity, Destination, radius: 0));
|
||||
}
|
||||
|
||||
private static WorldEntity CreateEntity(uint? parentCell, bool withPalette) => new()
|
||||
{
|
||||
Id = 1,
|
||||
SourceGfxObjOrSetupId = 0x01000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(0x01000001u, Matrix4x4.Identity)],
|
||||
ParentCellId = parentCell,
|
||||
PaletteOverride = withPalette
|
||||
? new PaletteOverride(0x04000001u, Array.Empty<PaletteOverride.SubPaletteRange>())
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class WbTextureResolutionPolicyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, false, false, (int)WbTextureResolutionKind.SharedAtlas)]
|
||||
[InlineData(false, true, false, (int)WbTextureResolutionKind.SharedAtlas)]
|
||||
[InlineData(true, false, false, (int)WbTextureResolutionKind.OriginalTextureOverride)]
|
||||
[InlineData(true, true, false, (int)WbTextureResolutionKind.OriginalTextureOverride)]
|
||||
[InlineData(false, true, true, (int)WbTextureResolutionKind.PaletteComposite)]
|
||||
[InlineData(true, true, true, (int)WbTextureResolutionKind.PaletteComposite)]
|
||||
public void Select_MatchesRetailImageOwnership(
|
||||
bool hasOriginalTextureOverride,
|
||||
bool hasPaletteOverride,
|
||||
bool sourceIsPaletteIndexed,
|
||||
int expected)
|
||||
{
|
||||
Assert.Equal((WbTextureResolutionKind)expected, WbTextureResolutionPolicy.Select(
|
||||
hasOriginalTextureOverride,
|
||||
hasPaletteOverride,
|
||||
sourceIsPaletteIndexed));
|
||||
}
|
||||
}
|
||||
91
tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs
Normal file
91
tests/AcDream.App.Tests/RuntimeDatAccessArchitectureTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
40
tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs
Normal file
40
tests/AcDream.App.Tests/RuntimeDatCollectionFactoryTests.cs
Normal 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!));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>());
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
118
tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs
Normal file
118
tests/AcDream.App.Tests/UI/UiCompositeShutdownTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
67
tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs
Normal file
67
tests/AcDream.App.Tests/UI/UiElementChildOrderTests.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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>(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>());
|
||||
|
||||
|
|
|
|||
57
tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs
Normal file
57
tests/AcDream.App.Tests/World/LiveEntityTeardownPlanTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
125
tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
Normal file
125
tests/AcDream.Content.Tests/BoundedDatObjectCacheTests.cs
Normal 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 };
|
||||
}
|
||||
152
tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs
Normal file
152
tests/AcDream.Content.Tests/DecodedTextureCacheTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs
Normal file
50
tests/AcDream.Core.Tests/Plugins/WorldGameStateTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -421,7 +421,6 @@ public sealed class WbDrawDispatcherBucketingTests
|
|||
Vector3? localSortCenter = null)
|
||||
{
|
||||
var key = new GroupKey(
|
||||
Ibo: ibo,
|
||||
FirstIndex: firstIndex,
|
||||
BaseVertex: 0,
|
||||
IndexCount: indexCount,
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue