#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>
This commit is contained in:
Erik 2026-06-11 21:43:45 +02:00
parent 3cf6bcc219
commit 2163308032
4 changed files with 122 additions and 10 deletions

View file

@ -49,6 +49,66 @@ public class EntityClassificationCacheTests
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()
{