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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 17:47:57 +02:00
parent aea4460eae
commit a171e7007b
2 changed files with 71 additions and 0 deletions

View file

@ -71,4 +71,30 @@ internal sealed class EntityClassificationCache
{
_entries.Remove(entityId);
}
/// <summary>
/// Remove every cache entry whose <see cref="EntityCacheEntry.LandblockHint"/>
/// equals <paramref name="landblockId"/>. Used by the streaming pipeline
/// when a landblock demotes from near to far or unloads. No-op if no
/// entries match.
/// </summary>
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<uint>? toRemove = null;
foreach (var (id, entry) in _entries)
{
if (entry.LandblockHint == landblockId)
{
toRemove ??= new List<uint>();
toRemove.Add(id);
}
}
if (toRemove is null) return;
foreach (var id in toRemove) _entries.Remove(id);
}
}

View file

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