From a171e7007b3e5ab562afc67f48c22fe9ae09dc86 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 10 May 2026 17:47:57 +0200 Subject: [PATCH] feat(render #53): EntityClassificationCache.InvalidateLandblock + tests Sweep-by-landblock removal for the streaming demote/unload path. Tests #6, #7, #8 from spec section 7.1 lock in: (a) all matching entries removed, (b) non-matching entries preserved, (c) idempotent on missing LB. Phase 1 (cache foundation) complete. 11 cache tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/EntityClassificationCache.cs | 26 +++++++++++ .../Wb/EntityClassificationCacheTests.cs | 45 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs index 7d5a65b..1b0bebf 100644 --- a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs +++ b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs @@ -71,4 +71,30 @@ internal sealed class EntityClassificationCache { _entries.Remove(entityId); } + + /// + /// Remove every cache entry whose + /// equals . Used by the streaming pipeline + /// when a landblock demotes from near to far or unloads. No-op if no + /// entries match. + /// + public void InvalidateLandblock(uint landblockId) + { + if (_entries.Count == 0) return; + + // Collect the ids to remove first to avoid mutating the dict during iteration. + // Buffered locally because the typical case removes ~all entries in the LB + // (which is still small relative to the total cache). + List? toRemove = null; + foreach (var (id, entry) in _entries) + { + if (entry.LandblockHint == landblockId) + { + toRemove ??= new List(); + toRemove.Add(id); + } + } + if (toRemove is null) return; + foreach (var id in toRemove) _entries.Remove(id); + } } diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs index a7949c1..4766461 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs @@ -126,6 +126,51 @@ public class EntityClassificationCacheTests Assert.Equal(0, cache.Count); } + [Fact] + public void InvalidateLandblock_RemovesAllMatchingEntries() + { + var cache = new EntityClassificationCache(); + cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) }); + cache.Populate(2, 0xA9B40000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) }); + cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) }); + Assert.Equal(3, cache.Count); + + cache.InvalidateLandblock(0xA9B40000u); + + Assert.Equal(0, cache.Count); + Assert.False(cache.TryGet(1, out _)); + Assert.False(cache.TryGet(2, out _)); + Assert.False(cache.TryGet(3, out _)); + } + + [Fact] + public void InvalidateLandblock_LeavesNonMatchingEntries() + { + var cache = new EntityClassificationCache(); + cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) }); + cache.Populate(2, 0xA9B50000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) }); + cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) }); + + cache.InvalidateLandblock(0xA9B40000u); + + Assert.Equal(1, cache.Count); + Assert.False(cache.TryGet(1, out _)); + Assert.True(cache.TryGet(2, out var keep)); + Assert.NotNull(keep); + Assert.Equal(0xA9B50000u, keep!.LandblockHint); + Assert.False(cache.TryGet(3, out _)); + } + + [Fact] + public void InvalidateLandblock_OnMissingLb_NoThrow() + { + var cache = new EntityClassificationCache(); + cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) }); + var ex = Record.Exception(() => cache.InvalidateLandblock(0xDEADBEEFu)); + Assert.Null(ex); + Assert.Equal(1, cache.Count); + } + private static CachedBatch MakeCachedBatch( uint ibo, uint firstIndex, int indexCount, ulong texHandle) {