using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using AcDream.Core.Meshing; using AcDream.Core.World; using Xunit; namespace AcDream.Core.Tests.Rendering.Wb; /// /// Tests for — the pure-CPU /// visibility filter extracted in A.5 T17. These tests exercise the two /// key perf changes from Phase A.5 spec §4.6: /// /// /// Change #1 (T17): invisible LB + animated set → iterate /// animatedEntityIds directly, not the full entity list. /// Change #2 (T18): per-entity AABB cull reads the cached AABB /// (/AabbMax) rather than /// recomputing Position±5 per frame. /// /// public sealed class WbDrawDispatcherBucketingTests { // ── helpers ────────────────────────────────────────────────────────────── private static WorldEntity MakeEntity(uint id, Vector3 position) => new WorldEntity { Id = id, SourceGfxObjOrSetupId = 0, Position = position, Rotation = Quaternion.Identity, MeshRefs = System.Array.Empty(), }; private static WorldEntity MakeEntityWithMesh(uint id, Vector3 position) => new WorldEntity { Id = id, SourceGfxObjOrSetupId = 0, Position = position, Rotation = Quaternion.Identity, // Single dummy MeshRef so it passes the MeshRefs.Count == 0 guard. MeshRefs = new[] { new MeshRef { GfxObjId = 0x01000001u } }, }; private static Dictionary BuildById(IEnumerable entities) { var d = new Dictionary(); foreach (var e in entities) d[e.Id] = e; return d; } /// /// A frustum positioned at (1e6+1, 1e6+1, 1e6+1) looking toward (1e6, 1e6, 1e6) /// with a very narrow near/far. Any AABB near the origin (0..20000) is /// far behind the near plane and fails all six planes. /// private static FrustumPlanes MakeFarAwayFrustum() { var view = Matrix4x4.CreateLookAt( new Vector3(1e6f + 1f, 1e6f + 1f, 1e6f + 1f), new Vector3(1e6f, 1e6f, 1e6f), Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView( MathF.PI / 4f, 1f, 0.1f, 1f); return FrustumPlanes.FromViewProjection(view * proj); } // ── T17 Change #1 tests ─────────────────────────────────────────────── [Fact] public void WalkEntities_InvisibleLb_NoAnimated_SkipsEntireBlock() { // When LB is invisible AND animatedEntityIds is empty/null, // WalkEntities should not walk any entities at all. var entities = new List(); for (int i = 0; i < 500; i++) entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0))); var byId = BuildById(entities); var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xAAAA_FFFFu, new Vector3(10000, 10000, 10000), new Vector3(20000, 20000, 20000), entities, byId), }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: MakeFarAwayFrustum(), neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: null); Assert.Equal(0, result.EntitiesWalked); Assert.Empty(result.ToDraw); } [Fact] public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities() { // 1000 entities in an LB whose AABB is far outside the frustum. // Only entity Id=42 is in animatedEntityIds. // Pre-T17 behavior: walk all 1000 entities just to find #42. // Post-T17: walk only the 1 animated entity (EntitiesWalked == 1). const int Total = 1000; var entities = new List(Total); for (int i = 0; i < Total; i++) entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0))); var byId = BuildById(entities); var animatedSet = new HashSet { 42 }; var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xAAAA_FFFFu, new Vector3(10000, 10000, 10000), new Vector3(20000, 20000, 20000), entities, byId), }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: MakeFarAwayFrustum(), neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: animatedSet); // Only the 1 animated entity should be walked — not 1000. Assert.Equal(1, result.EntitiesWalked); Assert.Single(result.ToDraw); Assert.Equal(42u, result.ToDraw[0].Entity.Id); } [Fact] public void WalkEntities_InvisibleLb_AnimatedIdAbsent_ZeroWalked() { // Animated entity ids 200 and 300 are NOT in this LB (which only // has ids 0..99). Should produce zero walks. var entities = new List(); for (int i = 0; i < 100; i++) entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero)); var byId = BuildById(entities); var animatedSet = new HashSet { 200, 300 }; // not in this LB var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xBBBB_FFFFu, new Vector3(10000, 10000, 10000), new Vector3(20000, 20000, 20000), entities, byId), }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: MakeFarAwayFrustum(), neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: animatedSet); Assert.Equal(0, result.EntitiesWalked); Assert.Empty(result.ToDraw); } [Fact] public void WalkEntities_NeverCullLb_WalksAllEntitiesRegardlessOfFrustum() { // neverCullLandblockId bypasses the LB AABB check entirely. // All entities with at least one MeshRef should be walked. var entities = new List { MakeEntityWithMesh(1, Vector3.Zero), MakeEntityWithMesh(2, Vector3.Zero), MakeEntityWithMesh(3, Vector3.Zero), }; var byId = BuildById(entities); const uint lbId = 0xCCCC_FFFFu; var entries = new[] { new WbDrawDispatcher.LandblockEntry( lbId, new Vector3(10000, 10000, 10000), // AABB would fail frustum new Vector3(20000, 20000, 20000), entities, byId), }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: MakeFarAwayFrustum(), neverCullLandblockId: lbId, // exempt from LB cull visibleCellIds: null, animatedEntityIds: null); Assert.Equal(3, result.EntitiesWalked); } [Fact] public void WalkEntities_NullFrustum_WalksEntitiesWithMeshRefs() { // Null frustum means no culling — all entities with MeshRefs pass. // Entities without MeshRefs are still filtered out. var entities = new List { MakeEntityWithMesh(1, Vector3.Zero), MakeEntity(2, Vector3.Zero), // no MeshRefs — must be skipped MakeEntityWithMesh(3, Vector3.Zero), }; var byId = BuildById(entities); var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xDDDD_FFFFu, Vector3.Zero, Vector3.Zero, entities, byId), }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: null, neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: null); Assert.Equal(2, result.EntitiesWalked); Assert.Equal(2, result.ToDraw.Count); } // ── T18 Change #2 tests ─────────────────────────────────────────────── [Fact] public void WalkEntities_VisibleLb_EntityFarAway_CulledViaCachedAabb() { // LB passes the LB-level cull; entity AABB is far from the frustum. // After RefreshAabb the entity should be culled by the per-entity check. var entity = MakeEntityWithMesh(1, new Vector3(50000, 50000, 50000)); entity.RefreshAabb(); // populate cached AABB at (50000±5) var byId = BuildById(new[] { entity }); var entries = new[] { // LB AABB near origin so it passes the LB cull; entity is far away. new WbDrawDispatcher.LandblockEntry( 0xEEEE_FFFFu, new Vector3(-10, -10, -10), new Vector3(10, 10, 10), new List { entity }, byId), }; // Frustum centered at origin, range ±100. var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f); var tightFrustum = FrustumPlanes.FromViewProjection(view * proj); var result = WbDrawDispatcher.WalkEntities( entries, frustum: tightFrustum, neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: null); // Entity at (50000,50000,50000) is outside the frustum — should be culled. Assert.Equal(0, result.EntitiesWalked); } [Fact] public void WalkEntities_AnimatedEntity_BypassesPerEntityAabbCull() { // Animated entities must always pass even if their AABB would be culled. var entity = MakeEntityWithMesh(7, new Vector3(50000, 50000, 50000)); entity.RefreshAabb(); var byId = BuildById(new[] { entity }); var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xEEEF_FFFFu, new Vector3(-10, -10, -10), new Vector3(10, 10, 10), new List { entity }, byId), }; var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f); var tightFrustum = FrustumPlanes.FromViewProjection(view * proj); var animatedSet = new HashSet { 7 }; var result = WbDrawDispatcher.WalkEntities( entries, frustum: tightFrustum, neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: animatedSet); // Animated entity bypasses per-entity cull. Assert.Equal(1, result.EntitiesWalked); Assert.Single(result.ToDraw); Assert.Equal(7u, result.ToDraw[0].Entity.Id); } [Fact] public void WalkEntities_AabbDirty_RefreshedLazilyBeforeCull() { // An entity with AabbDirty=true (initial state) should get its AABB // refreshed lazily by WalkEntities before the cull check. var entity = MakeEntityWithMesh(5, new Vector3(0, 0, 0)); // AabbDirty starts true by default — do NOT call RefreshAabb manually. Assert.True(entity.AabbDirty); var byId = BuildById(new[] { entity }); var entries = new[] { new WbDrawDispatcher.LandblockEntry( 0xF0F0_FFFFu, new Vector3(-10, -10, -10), new Vector3(10, 10, 10), new List { entity }, byId), }; // A frustum that accepts things near origin. var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ); var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.1f, 200f); var nearOriginFrustum = FrustumPlanes.FromViewProjection(view * proj); var result = WbDrawDispatcher.WalkEntities( entries, frustum: nearOriginFrustum, neverCullLandblockId: null, visibleCellIds: null, animatedEntityIds: null); // Entity at origin is inside the frustum after lazy RefreshAabb. Assert.Equal(1, result.EntitiesWalked); // AabbDirty should have been cleared by the lazy refresh. Assert.False(entity.AabbDirty); } // ── Tier 1 cache (#53) dispatcher integration tests ────────────────────── // // Tasks 9 & 10 wire the EntityClassificationCache into Draw's per-entity // loop. These tests exercise the populate + cache-hit fast-path algorithm // through the static helpers Draw uses (MaybeFlushOnEntityChange, // FinalFlushPopulate, ApplyCacheHit). The helpers were extracted from // Draw's foreach for testability — Draw calls them; tests drive them // directly with deterministic synthesized inputs. This is the same // pattern WalkEntities follows (extracted from Draw, tested in isolation). // // The tests cover spec §7.2 #11 (static populate + reuse) and #12 // (animated bypass), plus a multi-MeshRef regression test that would // have caught the bug fixed in commit 00fa8ae (per-MeshRef Populate // overwrites earlier batches because the cache is keyed by entity.Id). /// /// Helper: constructs a CachedBatch with stable group-key inputs so the /// hit-path test can verify membership. Mirrors the shape ClassifyBatches /// produces under the collector pattern. /// private static CachedBatch MakeCachedBatch( uint ibo, uint firstIndex, int indexCount, ulong texHandle, Matrix4x4? restPose = null) { var key = new GroupKey( Ibo: ibo, FirstIndex: firstIndex, BaseVertex: 0, IndexCount: indexCount, BindlessTextureHandle: texHandle, TextureLayer: 0, Translucency: TranslucencyKind.Opaque); return new CachedBatch(key, texHandle, restPose ?? Matrix4x4.Identity); } [Fact] public void Draw_StaticEntity_PopulatesCacheOnFirstFrameAndHitsOnSecond() { // Spec §7.2 test #11. // Drives Draw's populate + cache-hit algorithm through the production // static helpers. Verifies that: // 1. First "frame": cache is empty → populate fires once at the // end-of-loop final flush (entity.Id=100 has 2 batches). // 2. Second "frame": cache.TryGet(100) hits → ApplyCacheHit appends // cached batches to a fresh _groups dict without re-populating. // cache.Count stays at 1 (Populate is idempotent via overwrite, // but the hit-path doesn't re-populate at all). var cache = new EntityClassificationCache(); var scratch = new List(); Assert.Equal(0, cache.Count); // Frame 1: simulate one foreach iteration producing 2 batches for // entity 100 in landblock 0xA9B40000. With no prior tracker, the // entity-change flush is a no-op. ClassifyBatches' collector adds // to scratch. The end-of-loop FinalFlushPopulate commits. const uint EntityId = 100; const uint LandblockId = 0xA9B40000u; // First MeshRef contributes 2 batches (mimics ClassifyBatches output). scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA)); scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB)); uint? populateEntityId = null; uint populateLandblockId = 0u; // First-tuple boundary check: no flush, sets the tracker. (populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange( populateEntityId, populateLandblockId, EntityId, cache, scratch); // After ClassifyBatches the loop sets the tracker (matching Draw). populateEntityId = EntityId; populateLandblockId = LandblockId; // End-of-loop final flush — this is where the cache populates. WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch); // First-frame post-conditions: 1 cache entry, 2 batches in it. Assert.Equal(1, cache.Count); Assert.True(cache.TryGet(EntityId, LandblockId, out var entry)); Assert.NotNull(entry); Assert.Equal(2, entry!.Batches.Length); Assert.Equal(0xAAul, entry.Batches[0].BindlessTextureHandle); Assert.Equal(0xBBul, entry.Batches[1].BindlessTextureHandle); // Frame 2: cache hit. ApplyCacheHit walks the cached batches and // appends RestPose * entityWorld to a per-frame group dict. // Production code: this is the !isAnimated && _cache.TryGet branch // at the top of the per-entity loop body in Draw. var groups = new Dictionary>(); void AppendInstance(GroupKey k, Matrix4x4 m) { if (!groups.TryGetValue(k, out var list)) { list = new List(); groups[k] = list; } list.Add(m); } Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit)); Assert.NotNull(entryHit); var entityWorld = Matrix4x4.CreateTranslation(new Vector3(10f, 20f, 30f)); WbDrawDispatcher.ApplyCacheHit(entryHit!, entityWorld, AppendInstance); // Cache state stable — Populate didn't fire on the hit path. Assert.Equal(1, cache.Count); // Both groups received exactly one matrix each (the entity is one // instance contributing once per cached batch). Assert.Equal(2, groups.Count); foreach (var (_, list) in groups) Assert.Single(list); // Matrix composition is RestPose * entityWorld (NOT the reverse). // RestPose is Matrix4x4.Identity for the synthesized batches, so the // appended matrix must equal entityWorld. foreach (var (_, list) in groups) Assert.Equal(entityWorld, list[0]); } [Fact] public void Draw_AnimatedEntity_DoesNotPopulateCache() { // Spec §7.2 test #12. // Animated entities take the slow path with collector=null: their // ClassifyBatches output is NOT routed into _populateScratch and the // populate-tracking locals stay null. Result: the cache is never // populated for animated entities, and FinalFlushPopulate is a no-op. // // This test models that flow: scratch stays empty, populateEntityId // stays null, FinalFlushPopulate fires but commits nothing. var cache = new EntityClassificationCache(); var scratch = new List(); const uint AnimatedId = 7; const uint LandblockId = 0xA9B40000u; var animatedSet = new HashSet { AnimatedId }; // Even when the entity has MeshRefs that would produce batches, the // animated-set membership means collector=null in Draw — scratch // stays empty and the tracker stays null. Simulating that here: // we do NOT add to scratch and we do NOT set populateEntityId. bool isAnimated = animatedSet.Contains(AnimatedId); Assert.True(isAnimated); uint? populateEntityId = null; uint populateLandblockId = 0u; // Boundary check still runs but is a no-op — tracker is null. (populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange( populateEntityId, populateLandblockId, AnimatedId, cache, scratch); // For animated entities, Draw does NOT set populateEntityId after // ClassifyBatches (the `if (collector is not null)` guard). // populateEntityId stays null. // End-of-loop flush — no-op for animated-only iterations. WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch); // Cache should never be populated for animated entities. Assert.Equal(0, cache.Count); Assert.False(cache.TryGet(AnimatedId, LandblockId, out _)); } [Fact] public void Draw_MultiMeshRefStaticEntity_PopulatesAllBatchesIntoSingleCacheEntry() { // Regression test for the bug fixed at commit 00fa8ae: // // Task 9's first attempt called _cache.Populate per-(entity, // MeshRefIndex) tuple, but the cache is keyed by entity.Id. For // multi-MeshRef entities (multi-part Setup buildings, statues, // NPCs), each iteration's Populate OVERWROTE the previous one // — only the LAST MeshRef's batches survived in the cache. After // the fix, Populate fires once per entity at the entity boundary // (or end-of-loop), with all MeshRefs' batches accumulated into // _populateScratch. // // This test simulates a 3-MeshRef static entity where each MeshRef // contributes 2 batches (total = 6). It walks through Draw's loop // structure tuple-by-tuple, calling MaybeFlushOnEntityChange before // each tuple's classification and FinalFlushPopulate at end-of-loop. // Asserts the cache entry holds ALL 6 batches, not just the last 2. // // If the per-MeshRef Populate bug were reintroduced, this test would // see Batches.Length == 2 (last MeshRef only). var cache = new EntityClassificationCache(); var scratch = new List(); const uint EntityId = 200; const uint LandblockId = 0xA9B40000u; const int MeshRefCount = 3; const int BatchesPerMeshRef = 2; const int ExpectedTotalBatches = MeshRefCount * BatchesPerMeshRef; uint? populateEntityId = null; uint populateLandblockId = 0u; // Simulate Draw's foreach over _walkScratch. _walkScratch yields // (entity, MeshRefIndex, landblockId) — all MeshRefs of one entity // are contiguous because the walk emits them in entity-order. for (int meshRefIdx = 0; meshRefIdx < MeshRefCount; meshRefIdx++) { // Boundary check: same entity across all 3 iterations, so this // never fires the flush. populateEntityId stays as is (null on // first iter; EntityId on subsequent iters after we set it). (populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange( populateEntityId, populateLandblockId, EntityId, cache, scratch); // Mimic ClassifyBatches' collector output for THIS MeshRef: // 2 batches with distinct (ibo, firstIndex, texHandle) so the // ordering can be verified post-hoc. for (int b = 0; b < BatchesPerMeshRef; b++) { ulong texHandle = (ulong)(0x100 + meshRefIdx * BatchesPerMeshRef + b); scratch.Add(MakeCachedBatch( ibo: (uint)(meshRefIdx + 1), firstIndex: (uint)(b * 6), indexCount: 6, texHandle: texHandle)); } // After ClassifyBatches, Draw sets the tracker (matching the // `if (collector is not null)` block at line 482-486 in // WbDrawDispatcher.Draw). populateEntityId = EntityId; populateLandblockId = LandblockId; } // End-of-loop final flush. Without this call (or if Populate fired // per-tuple inside the loop), the cache would only hold the last // 2 batches — exactly the bug class from commit 00fa8ae. WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch); // Assertions: ONE cache entry with ALL 6 batches in MeshRef order. Assert.Equal(1, cache.Count); Assert.True(cache.TryGet(EntityId, LandblockId, out var entry)); Assert.NotNull(entry); Assert.Equal(EntityId, entry!.EntityId); Assert.Equal(LandblockId, entry.LandblockHint); // KEY ASSERTION: Batches.Length == sum across MeshRefs (6), // NOT just the last MeshRef's batch count (2). Assert.Equal(ExpectedTotalBatches, entry.Batches.Length); // Per-batch ordering check: batches arrived in MeshRef order, so // texture handles run 0x100..0x105 in the order they were appended. for (int i = 0; i < ExpectedTotalBatches; i++) Assert.Equal((ulong)(0x100 + i), entry.Batches[i].BindlessTextureHandle); // After flush, scratch is cleared so the next entity starts fresh. Assert.Empty(scratch); } [Fact] public void Cache_Populate_SkipsEntityWithIncompleteClassification() { // Regression test for the bug where an entity with >=1 MeshRef whose // mesh data was still async-decoding at populate time would have a // PARTIAL set of batches written to the cache. Subsequent frame // cache-hits served the partial entry indefinitely, leaving parts of // multi-part entities (drudge statue, etc.) permanently missing. // // The fix: track currentEntityIncomplete during the foreach. If any // tuple's TryGetRenderData returned null, drop the accumulated // populate scratch at entity boundary instead of caching it. The // slow path retries on the next frame; once all meshes have loaded, // the populate fires correctly with the complete classification. // // This test simulates Draw's inner-loop logic: 3 MeshRef tuples for // one entity where tuple 0 produces null renderData (flag the entity // incomplete + continue, no batches), and tuples 1 and 2 produce // valid renderData (classify + accumulate). End-of-loop check drops // scratch + nulls populateEntityId BEFORE FinalFlushPopulate, so the // cache stays empty for this entity. var cache = new EntityClassificationCache(); const uint EntityId = 100; const uint LandblockId = 0xA9B40000u; // Simulate Draw's per-entity inner-loop logic. var scratch = new List(); bool currentEntityIncomplete = false; uint? populateEntityId = null; uint populateLandblockId = 0u; // Tuple 0 (MeshRef[0]): renderData null -> flag incomplete, skip classify. currentEntityIncomplete = true; // Tuple 1 (MeshRef[1]): renderData valid -> classify, accumulate. scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAAul)); populateEntityId = EntityId; populateLandblockId = LandblockId; // Tuple 2 (MeshRef[2]): renderData valid -> classify, accumulate. scratch.Add(MakeCachedBatch(ibo: 2, firstIndex: 0, indexCount: 6, texHandle: 0xBBul)); populateEntityId = EntityId; populateLandblockId = LandblockId; // End of loop: check incomplete flag, drop scratch + null tracker // BEFORE FinalFlushPopulate so the helper sees the cleaned state. if (currentEntityIncomplete) { scratch.Clear(); populateEntityId = null; } WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch); // Cache should NOT have an entry for this entity — partial population // would be worse than no cache (cache hit would persist the partial // render forever; cache miss retries and gets it right next frame). Assert.Equal(0, cache.Count); Assert.False(cache.TryGet(EntityId, LandblockId, out _)); } [Fact] public void ApplyCacheHit_PerTupleAmplification_DoesNotOccur() { // Regression test for the bug fixed at the commit landing alongside // this test: Task 10's first attempt called ApplyCacheHit per-(entity, // MeshRefIndex) tuple in Draw's foreach, but cachedEntry.Batches is // flat across all MeshRefs of the entity. For a 3-MeshRef building on // frame 2: 3 tuples × 6 cached batches per call = 18 instances drawn // instead of 6. Severe Z-fighting and 3× perf hit on every multi-part // static entity (buildings, statues, multi-MeshRef NPCs). // // This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae — // both came from spec §5.2 describing the foreach as per-entity when // _walkScratch is per-tuple. // // The fix: track lastHitEntityId; the cache-hit fast path fires only // on the FIRST tuple of each entity. Subsequent tuples of the same // entity skip the iteration body via continue. // // This test simulates the inner-loop logic by directly invoking // ApplyCacheHit + AppendInstanceToGroup the way Draw would, with N // tuples for the same entity, asserting that groups[key].Count equals // the cached batch count (6), NOT N × cached batch count (18). // Set up a synthetic cache entry with 6 batches (representing 3 // MeshRefs × 2 batches each). const int CachedBatchCount = 6; var cache = new EntityClassificationCache(); var batches = new CachedBatch[CachedBatchCount]; for (int i = 0; i < CachedBatchCount; i++) { batches[i] = MakeCachedBatch( ibo: 1u, firstIndex: (uint)i, indexCount: 6, texHandle: (ulong)(0x100 + i)); } cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches); // Simulate Draw's per-entity loop: 3 tuples for the same entity. // Track which entity has already cache-hit (mirrors the production // lastHitEntityId pattern). var groups = new Dictionary>(); uint? lastHitEntityId = null; var entityWorld = Matrix4x4.Identity; // simplest case for assertion clarity const uint EntityId = 100; const int MeshRefCount = 3; void AppendInstance(GroupKey k, Matrix4x4 m) { if (!groups.TryGetValue(k, out var list)) { list = new List(); groups[k] = list; } list.Add(m); } for (int partIdx = 0; partIdx < MeshRefCount; partIdx++) { // Skip subsequent tuples of an entity that cache-hit (the fix). if (lastHitEntityId == EntityId) continue; if (cache.TryGet(EntityId, 0xA9B40000u, out var entry)) { Assert.NotNull(entry); WbDrawDispatcher.ApplyCacheHit(entry!, entityWorld, AppendInstance); lastHitEntityId = EntityId; } } // Assertion: each group's matrix count equals the cached batches matching // that key, NOT (cached batches × MeshRef count). Here each batch has a // unique key, so each group has exactly 1 matrix. int totalMatrices = 0; foreach (var (_, matrices) in groups) totalMatrices += matrices.Count; Assert.Equal(CachedBatchCount, totalMatrices); // 6, NOT 18 // Sanity: 6 distinct keys (one per cached batch since FirstIndex differs). Assert.Equal(CachedBatchCount, groups.Count); } }