acdream/tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs
Erik 2163308032 #119 ROOT CAUSE: interior-id X-byte collision + player-landblock cache hints = cross-entity batch serving
The decisive probe (3cf6bcc) caught it live in ONE session: a 43-part
staircase entity (src=0x020003F2, healthy MeshRefs tZ=[0.35..15.15])
drew with cache=hit:3 restZero=3 - THREE batches belonging to a 1-part
entity - then under a different hint the correct hit:119. Two
compounding bugs:

1. interiorIdBase = 0x40000000 | (landblockId & 0x00FFFF00) resolved to
   0x40YYFF00 for landblock keys 0xXXYYFFFF - the landblock X byte
   DISCARDED. Every landblock in a map Y-row shared one id space:
   Holtburg town A9B3's 9th interior stab == the AAB3 tower's spiral
   staircase, both 0x40B3FF09. Fixed to 0x40000000|(lbX<<16)|(lbY<<8)
   (the scenery 0x80XXYY## scheme).

2. The Tier-1 classification cache's #53 tuple key (EntityId,
   LandblockHint) was fed the PLAYER's landblock at bucket-draw time
   (RetailPViewRenderer.DrawEntityBucket fabricates its tuple with
   ctx.PlayerLandblockId), so colliding ids from different landblocks
   shared a key: whichever entity classified first under a hint won,
   and the loser wore its batches all session (static fast path never
   re-classifies). Also: bucket-hinted entries were never swept by
   InvalidateLandblock(owner) - stale entries survived owner unload.
   Fixed: ResolveCacheLandblockHint derives the hint from the entity's
   owning cell (ParentCellId landblock, canonical 0xXXYYFFFF), falling
   back to the tuple id for ownerless paths (outdoor stabs/scenery,
   where the tuple IS the owner).

Explains the session-shaped repro exactly: town-login + run to the
tower hydrates/classifies town interiors first -> the tower staircase
cache-hits the town twin's batches (stairs missing/partial + a wrong
object near the floor - the "water barrel"); login-inside classifies
the tower first -> usually clean. meshMissing=0 / entSeen==entDrawn
both ways (everything draws, wrong batches). Likely also feeds #113's
distance-dependent phantom staircase (the town twin wearing the
tower's staircase batches).

3 new cache tests pin the collision contract + hint derivation.
Suites: App green / Core 1430+2skip / UI 420 / Net 294.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:43:45 +02:00

345 lines
13 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.Wb;
using AcDream.Core.Meshing;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
public class EntityClassificationCacheTests
{
[Fact]
public void TryGet_EmptyCache_ReturnsFalse()
{
var cache = new EntityClassificationCache();
bool found = cache.TryGet(entityId: 42, landblockHint: 0u, out var entry);
Assert.False(found);
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, 0xA9B40000u, 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, 0u, out var entry));
Assert.NotNull(entry);
Assert.Single(entry!.Batches);
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
}
// ── #119 root-cause regression (2026-06-11): colliding entity ids across
// landblocks must never share a cache entry. The bucket draw path used to
// hint every entity with the PLAYER's landblock, so the AAB3 tower's
// 43-part staircase (id 0x40B3FF09) cache-hit the batches of Holtburg
// town A9B3's 9th interior stab (same id under the old 0x40YYFF00
// namespace) — "broken stairs + water barrel". The hint must be derived
// from the entity's OWNER via WbDrawDispatcher.ResolveCacheLandblockHint.
[Fact]
public void ResolveCacheLandblockHint_InteriorEntity_DerivesOwnerLandblock()
{
var towerStairs = MakeEntity(id: 0x40AAB309u, parentCellId: 0xAAB30107u);
// Tuple landblock = the PLAYER's landblock (the bucket path) — must be ignored.
uint hint = WbDrawDispatcher.ResolveCacheLandblockHint(towerStairs, tupleLandblockId: 0xA9B3FFFFu);
Assert.Equal(0xAAB3FFFFu, hint);
}
[Fact]
public void ResolveCacheLandblockHint_NoParentCell_KeepsTupleLandblock()
{
var outdoorStab = MakeEntity(id: 0x00001234u, parentCellId: null);
uint hint = WbDrawDispatcher.ResolveCacheLandblockHint(outdoorStab, tupleLandblockId: 0xA9B4FFFFu);
Assert.Equal(0xA9B4FFFFu, hint);
}
[Fact]
public void CollidingEntityIds_UnderOwnerHints_KeepDistinctBatchSets()
{
// Same entity id (the residual >256-counter overlap the tuple key exists
// for), two different owning landblocks → two entries, each serving its
// own batches. Pre-fix, both would have been keyed under one player-lb
// hint and the second entity would draw the first one's batches.
var cache = new EntityClassificationCache();
var townBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
var towerBatches = new[] { MakeCachedBatch(2, 0, 12, 0xBB), MakeCachedBatch(2, 12, 6, 0xCC) };
cache.Populate(0x40B3FF09u, 0xA9B3FFFFu, townBatches);
cache.Populate(0x40B3FF09u, 0xAAB3FFFFu, towerBatches);
Assert.True(cache.TryGet(0x40B3FF09u, 0xA9B3FFFFu, out var town));
Assert.True(cache.TryGet(0x40B3FF09u, 0xAAB3FFFFu, out var tower));
Assert.Equal(townBatches, town!.Batches);
Assert.Equal(towerBatches, tower!.Batches);
// Owner-landblock invalidation sweeps ONLY the owner's entry.
cache.InvalidateLandblock(0xA9B3FFFFu);
Assert.False(cache.TryGet(0x40B3FF09u, 0xA9B3FFFFu, out _));
Assert.True(cache.TryGet(0x40B3FF09u, 0xAAB3FFFFu, out _));
}
private static AcDream.Core.World.WorldEntity MakeEntity(uint id, uint? parentCellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x020003F2u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = new List<AcDream.Core.World.MeshRef>(),
ParentCellId = parentCellId,
};
[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<CachedBatch>());
Assert.True(cache.TryGet(7, 0u, 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, 0u, 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);
}
[Fact]
public void InvalidateEntity_RemovesEntry()
{
var cache = new EntityClassificationCache();
cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
Assert.True(cache.TryGet(100, 0u, out _));
cache.InvalidateEntity(100);
Assert.False(cache.TryGet(100, 0u, out var entry));
Assert.Null(entry);
Assert.Equal(0, cache.Count);
}
[Fact]
public void InvalidateEntity_OnMissingId_NoThrow()
{
var cache = new EntityClassificationCache();
var ex = Record.Exception(() => cache.InvalidateEntity(99999));
Assert.Null(ex);
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, 0xA9B40000u, out _));
Assert.False(cache.TryGet(2, 0xA9B40000u, out _));
Assert.False(cache.TryGet(3, 0xA9B40000u, 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, 0xA9B40000u, out _));
Assert.True(cache.TryGet(2, 0xA9B50000u, out var keep));
Assert.NotNull(keep);
Assert.Equal(0xA9B50000u, keep!.LandblockHint);
Assert.False(cache.TryGet(3, 0xA9B40000u, 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);
}
[Fact]
public void DespawnRespawn_UnderReusedId_RepopulatesFresh()
{
// Pins the audit's ObjDescEvent contract (audit section 1):
// ObjDescEvent is despawn + respawn (with a NEW local entity.Id),
// never an in-place mutation. Even when an id IS reused
// (theoretical — _liveEntityIdCounter is monotonic in practice),
// the cache must serve fresh data after invalidation.
var cache = new EntityClassificationCache();
var batchesV1 = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
var batchesV2 = new[] { MakeCachedBatch(2, 6, 12, 0xCC) };
cache.Populate(100, 0xA9B40000u, batchesV1);
cache.InvalidateEntity(100);
cache.Populate(100, 0xA9B40000u, batchesV2);
Assert.True(cache.TryGet(100, 0xA9B40000u, out var entry));
Assert.NotNull(entry);
Assert.Equal(batchesV2, entry!.Batches);
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
}
#if DEBUG
[Fact]
public void DebugCrossCheck_BatchCountMismatch_FiresAssert()
{
var cache = new EntityClassificationCache();
cache.Populate(100, 0u, new[]
{
MakeCachedBatch(1, 0, 6, 0xAA),
MakeCachedBatch(1, 6, 6, 0xBB),
});
// Synthetic "live" with fewer batches → should fire Debug.Assert.
var liveBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
// Capture Debug.Assert via a custom TraceListener.
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
System.Diagnostics.Trace.Listeners.Clear();
var asserts = new List<string>();
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
try
{
cache.DebugCrossCheck(100, 0u, liveBatches);
}
finally
{
System.Diagnostics.Trace.Listeners.Clear();
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
}
Assert.NotEmpty(asserts);
string joined = string.Join(" ", asserts);
Assert.Contains("batch count mismatch", joined);
}
[Fact]
public void DebugCrossCheck_RestPoseMatch_NoAssert()
{
var cache = new EntityClassificationCache();
var batches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
cache.Populate(100, 0u, batches);
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
System.Diagnostics.Trace.Listeners.Clear();
var asserts = new List<string>();
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
try
{
cache.DebugCrossCheck(100, 0u, batches);
}
finally
{
System.Diagnostics.Trace.Listeners.Clear();
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
}
Assert.Empty(asserts);
}
private sealed class CaptureListener : System.Diagnostics.TraceListener
{
private readonly List<string> _captured;
public CaptureListener(List<string> captured) { _captured = captured; }
public override void Write(string? message) { if (message != null) _captured.Add(message); }
public override void WriteLine(string? message) { if (message != null) _captured.Add(message); }
public override void Fail(string? message, string? detailMessage)
{
_captured.Add($"{message}: {detailMessage}");
}
public override void Fail(string? message) { if (message != null) _captured.Add(message); }
}
#endif
private static CachedBatch MakeCachedBatch(
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
return new CachedBatch(key, texHandle, Matrix4x4.Identity);
}
}