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)
{