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