acdream/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherBucketingTests.cs
Erik 95ebbf3004 fix(render #53): key cache by (entityId, landblockHint) to defeat ID collision
User confirmed via A/B test (ACDREAM_DISABLE_TIER1_CACHE=1) that the
visual bug — buildings rendering up in the air outside Holtburg — is in
the cache wiring, not elsewhere. The matrix math (restPose * entityWorld
== model) was provably correct, so the bug had to be cache key collision.

Stabs were namespaced in commit 71d0edc, but scenery (0x80LLBB00 +
localIndex) and interior (0x40LLBB00 + localCounter) still have the
same 256-overflow risk. Dense LBs outside Holtburg (forest, urban) push
localIndex past 255, wrapping into the lbY byte and creating cross-LB
collisions.

Fix: change the cache key from uint entityId to (uint, uint) tuple of
(EntityId, LandblockHint). The cache is now correct-by-construction
regardless of any hydration path's Id-generation strategy. Defensive
against future regressions in any ID namespace.

InvalidateEntity becomes a sweep (was O(1)), but it's called rarely
(only on live-entity despawn). InvalidateLandblock was already a sweep.

Updated 14 existing cache tests + 1 dispatcher integration test to thread
landblockHint through TryGet / DebugCrossCheck calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:02:14 +02:00

684 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// Tests for <see cref="WbDrawDispatcher.WalkEntities"/> — 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:
///
/// <list type="bullet">
/// <item>Change #1 (T17): invisible LB + animated set → iterate
/// <c>animatedEntityIds</c> directly, not the full entity list.</item>
/// <item>Change #2 (T18): per-entity AABB cull reads the cached AABB
/// (<see cref="WorldEntity.AabbMin"/>/<c>AabbMax</c>) rather than
/// recomputing Position±5 per frame.</item>
/// </list>
/// </summary>
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<MeshRef>(),
};
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<uint, WorldEntity> BuildById(IEnumerable<WorldEntity> entities)
{
var d = new Dictionary<uint, WorldEntity>();
foreach (var e in entities) d[e.Id] = e;
return d;
}
/// <summary>
/// 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.
/// </summary>
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<WorldEntity>();
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<WorldEntity>(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<uint> { 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<WorldEntity>();
for (int i = 0; i < 100; i++)
entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero));
var byId = BuildById(entities);
var animatedSet = new HashSet<uint> { 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<WorldEntity>
{
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<WorldEntity>
{
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<WorldEntity> { 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<WorldEntity> { 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<uint> { 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<WorldEntity> { 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).
/// <summary>
/// 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.
/// </summary>
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<CachedBatch>();
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<GroupKey, List<Matrix4x4>>();
void AppendInstance(GroupKey k, Matrix4x4 m)
{
if (!groups.TryGetValue(k, out var list))
{
list = new List<Matrix4x4>();
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<CachedBatch>();
const uint AnimatedId = 7;
const uint LandblockId = 0xA9B40000u;
var animatedSet = new HashSet<uint> { 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<CachedBatch>();
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 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<GroupKey, List<Matrix4x4>>();
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<Matrix4x4>();
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);
}
}