fix(world #53): namespace stab Ids globally for Tier 1 cache safety

LandblockLoader.BuildEntitiesFromInfo restarted nextId at 1 per landblock,
producing colliding entity.Id values across landblocks. EntityClassificationCache
keys by entity.Id alone, so cross-LB collisions caused cache pollution:
multiple stabs sharing id=1 -> cache entry for id=1 ended up with the
CONCATENATION of multiple entities' batches -> buildings rendered up in the
air with wrong textures (visual gate observation 2026-05-10).

Audit at docs/research/2026-05-10-tier1-mutation-audit.md did not verify
entity.Id uniqueness - that was an unchecked assumption. Cache design
trusted entity.Id was globally unique; for stabs it wasn't.

Fix: optional landblockId parameter on BuildEntitiesFromInfo. When non-zero,
stab Ids are namespaced as 0xC0XXYY00 + nextId, matching the scenery
(0x80XXYY00) and interior (0x40XXYY00) namespacing already in GameWindow.cs.
The 0xC0 top byte distinguishes stabs from those. Existing tests pass
landblockId=0 and keep their legacy starting-from-1 behavior.

Known latent: if any one landblock has >256 stabs, nextId overflows the
low byte. Same pattern + same limitation as scenery/interior. Out of scope
for the immediate Tier 1 cache bug; not affecting current Holtburg play.

Adds 2 regression tests pinning the namespacing + the legacy fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-10 20:07:19 +02:00
parent 4df19146ff
commit 71d0edc3d7
2 changed files with 66 additions and 3 deletions

View file

@ -22,7 +22,7 @@ public static class LandblockLoader
var info = dats.Get<LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is null
? Array.Empty<WorldEntity>()
: BuildEntitiesFromInfo(info);
: BuildEntitiesFromInfo(info, landblockId);
return new LoadedLandblock(landblockId, block, entities);
}
@ -33,10 +33,27 @@ public static class LandblockLoader
/// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped.
/// MeshRefs is left empty at this stage — Task 5 populates it.
/// </summary>
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info)
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info, uint landblockId = 0)
{
var result = new List<WorldEntity>(info.Objects.Count + info.Buildings.Count);
uint nextId = 1;
// When landblockId is non-zero, namespace stab Ids globally:
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
// matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns
// in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those.
//
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
// legacy starting-from-1 monotonic Ids — compatible with their assertions
// which check uniqueness within a single landblock.
//
// Latent: if a landblock has >256 stabs (rare), nextId overflows the
// low byte and bleeds into the lbY byte → cross-LB collision. Same
// pattern + same limitation as scenery/interior. Document but don't
// fix in this commit — out of scope for the Tier 1 cache bug fix.
uint stabIdBase = landblockId == 0
? 0u
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u;
foreach (var stab in info.Objects)
{