diff --git a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs
index 0ae7cfc..5683346 100644
--- a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs
+++ b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs
@@ -48,4 +48,18 @@ internal sealed class EntityClassificationCache
///
public bool TryGet(uint entityId, out EntityCacheEntry? entry)
=> _entries.TryGetValue(entityId, out entry);
+
+ ///
+ /// Insert or overwrite a cache entry for .
+ /// Defensive: if an entry already exists, replaces it.
+ ///
+ public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
+ {
+ _entries[entityId] = new EntityCacheEntry
+ {
+ EntityId = entityId,
+ LandblockHint = landblockHint,
+ Batches = batches,
+ };
+ }
}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
index b60b34b..6f37bff 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
@@ -17,6 +17,92 @@ public class EntityClassificationCacheTests
Assert.Null(entry);
}
+ [Fact]
+ public void Populate_ThenTryGet_ReturnsBatchesInOrder()
+ {
+ var cache = new EntityClassificationCache();
+ var batches = new[]
+ {
+ MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA),
+ MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB),
+ };
+
+ cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);
+
+ Assert.True(cache.TryGet(100, out var entry));
+ Assert.NotNull(entry);
+ Assert.Equal(100u, entry!.EntityId);
+ Assert.Equal(0xA9B40000u, entry.LandblockHint);
+ Assert.Equal(batches, entry.Batches);
+ }
+
+ [Fact]
+ public void Populate_OverridesExistingEntry()
+ {
+ var cache = new EntityClassificationCache();
+ cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
+ cache.Populate(100, 0u, new[] { MakeCachedBatch(2, 0, 12, 0xCC) });
+
+ Assert.True(cache.TryGet(100, out var entry));
+ Assert.NotNull(entry);
+ Assert.Single(entry!.Batches);
+ Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
+ }
+
+ [Fact]
+ public void Count_TracksLiveEntries()
+ {
+ var cache = new EntityClassificationCache();
+ Assert.Equal(0, cache.Count);
+
+ cache.Populate(1, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
+ Assert.Equal(1, cache.Count);
+
+ cache.Populate(2, 0u, new[] { MakeCachedBatch(2, 0, 6, 0xAA) });
+ Assert.Equal(2, cache.Count);
+
+ // Re-populate same id — should not double-count.
+ cache.Populate(1, 0u, new[] { MakeCachedBatch(3, 0, 6, 0xBB) });
+ Assert.Equal(2, cache.Count);
+ }
+
+ [Fact]
+ public void Populate_WithEmptyBatches_StoresEmptyEntry()
+ {
+ var cache = new EntityClassificationCache();
+ cache.Populate(entityId: 7, landblockHint: 0u, System.Array.Empty());
+
+ Assert.True(cache.TryGet(7, out var entry));
+ Assert.NotNull(entry);
+ Assert.Empty(entry!.Batches);
+ }
+
+ [Fact]
+ public void Populate_SetupMultiPart_StoresFlatBatchPerSubPart()
+ {
+ // Synthetic Setup with 3 subParts × 2 batches each = 6 flat entries.
+ // This pins the spec §3 Q4 decision: pre-flatten Setup multi-parts at
+ // populate time so the per-frame hot path is branchless.
+ var cache = new EntityClassificationCache();
+ var batches = new CachedBatch[6];
+ for (int subPart = 0; subPart < 3; subPart++)
+ for (int b = 0; b < 2; b++)
+ {
+ batches[subPart * 2 + b] = MakeCachedBatch(
+ ibo: (uint)(subPart + 1),
+ firstIndex: (uint)(b * 6),
+ indexCount: 6,
+ texHandle: (ulong)(0x100 + subPart * 2 + b));
+ }
+ cache.Populate(99, 0u, batches);
+
+ Assert.True(cache.TryGet(99, out var entry));
+ Assert.NotNull(entry);
+ Assert.Equal(6, entry!.Batches.Length);
+ Assert.Equal(0x100u, entry.Batches[0].BindlessTextureHandle);
+ Assert.Equal(0x105u, entry.Batches[5].BindlessTextureHandle);
+ }
+
private static CachedBatch MakeCachedBatch(
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
{