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
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 })
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue