fix(#190): interior entity id counter overflowed past its 8-bit budget, aliasing into the next landblock

Found while investigating #189 (missing fountain/candle particles):
reverting the A7.L1 light-carrier hydration fix (9ebb2060) made the
Town Network fountain's water-spray particle work again, which didn't
fit the earlier dat-truth finding that the fountain's own entity was
never touched by that fix. Traced with ACDREAM_DUMP_ENTITY: the
fountain's hydrated entity.Id shifted between reverted (0x400007F8)
and fixed (0x40000815) builds — a 29-id delta matching the extra
mesh-less light carriers the A7.L1 fix now keeps alive earlier in the
same landblock's hydration pass.

Root cause: GameWindow's interior-entity id scheme
(interiorIdBase + localCounter, "0x40XXYY##") reserves only 8 bits
(256 values) for a landblock's ENTIRE interior static population — a
residual explicitly flagged in the #119 fix's own comment ("counter
overflow past 0xFF still bleeds into the lbY byte"). The Town Network
hub (205 cells, one landblock) already sat at 248 before A7.L1; the
light fix pushed it to 277, past the boundary. 0x40000815 decodes as
landblock Y=0x08 — NOT this dungeon's true Y=0x07 — the exact #119
cross-landblock aliasing bug, reincarnated by entity count instead of
a computation bug. EntityScriptActivator keys particle-script
instances by entity.Id directly (no landblock-hint disambiguation
unlike the #119 batch cache), so the aliased id silently broke the
fountain's script tracking.

Fix: AcDream.Core.World.InteriorEntityIdAllocator widens the counter
8->12 bits (256->4096) by shrinking the fixed class prefix from a
full byte (0x40) to its top nibble (0x4_) — verified safe against
every entity.Id classification check in GameWindow (none decode X/Y
back out, they only check thresholds/prefixes). Added a loud
one-time [id-overflow] log if a landblock ever exceeds the new
budget, so this class of bug can never hide silently again.

Core 2675+2skip / App 741+2skip / UI 425 / Net 385 green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-09 12:21:57 +02:00
parent f6b054b7d7
commit e651cb6dd1
3 changed files with 165 additions and 7 deletions

View file

@ -0,0 +1,90 @@
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
/// <summary>
/// #190 (2026-07-09) — the interior entity id space (<c>0x40XXYY##</c>,
/// GameWindow.cs's <c>interiorIdBase + localCounter</c>) reserved only 8 bits
/// (256 values) for a landblock's WHOLE interior static population. The Town
/// Network hub (205 cells) reached 248 before the #79/#93 A7.L1 light fix,
/// then 277 after it — PAST the 8-bit boundary, silently aliasing into the
/// next landblock's reserved Y-byte (0x40000815 decoded as landblock Y=0x08,
/// not the true Y=0x07). Same collision CLASS as #119 (two landblocks
/// sharing one id space), triggered by entity COUNT instead of a computation
/// bug. Widened the counter to 12 bits (4095) by shrinking the fixed class
/// prefix from a full byte (0x40) to its top nibble (0x4_) — verified safe
/// against every classification check that reads entity.Id
/// (GameWindow.cs's <c>_isOutdoorMesh</c> threshold, <c>_isLandblockStab</c>
/// prefix, scenery bit) since none of them decode X/Y back out.
/// </summary>
public class InteriorEntityIdAllocatorTests
{
[Fact]
public void Base_AlwaysAtOrAboveStabThreshold()
{
// GameWindow.cs: `entity.Id < 0x40000000u` classifies "stab" —
// every interior-static base must sit AT or ABOVE this floor for
// every landblock coordinate, including the corners.
for (uint y = 0; y <= 255; y += 51)
for (uint x = 0; x <= 255; x += 51)
Assert.True(InteriorEntityIdAllocator.Base(x, y) >= 0x40000000u);
}
[Fact]
public void Base_NeverMatchesLandblockStabPrefix()
{
// GameWindow.cs: `(entity.Id & 0xFF000000u) == 0xC0000000u` identifies
// LandBlockInfo stabs — an interior-static base must never collide.
for (uint y = 0; y <= 255; y += 51)
for (uint x = 0; x <= 255; x += 51)
Assert.NotEqual(0xC0000000u, InteriorEntityIdAllocator.Base(x, y) & 0xFF000000u);
}
[Fact]
public void Base_NeverSetsSceneryBit()
{
// GameWindow.cs: `(entity.Id & 0x80000000u) != 0` identifies procedural
// scenery — an interior-static base must never set bit 31.
for (uint y = 0; y <= 255; y += 51)
for (uint x = 0; x <= 255; x += 51)
Assert.Equal(0u, InteriorEntityIdAllocator.Base(x, y) & 0x80000000u);
}
[Fact]
public void MaxCounter_StaysWithinTheSameLandblocksReservedRange()
{
// The literal #190 repro, generalized: adding the full counter budget
// to a landblock's base must never spill into a DIFFERENT landblock's
// base. Checked against every adjacent-Y and adjacent-X neighbor.
for (uint y = 0; y < 255; y++)
{
uint thisBase = InteriorEntityIdAllocator.Base(0, y);
uint nextYBase = InteriorEntityIdAllocator.Base(0, y + 1);
Assert.True(thisBase + InteriorEntityIdAllocator.MaxCounter < nextYBase,
$"y={y}: counter budget overflows into landblock y={y + 1}'s id range");
}
}
[Fact]
public void TownNetworkRepro_277EntitiesStaysInTheSameLandblock()
{
// The exact #190 repro values: landblock 0x0007xxxx (X=0, Y=7), 277
// hydrated entities (the count AFTER the #79/#93 A7.L1 light fix).
// Under the OLD 8-bit-counter scheme this aliased into Y=8's range
// (0x40000815 read back as Y=0x08). Under the new scheme it must not.
uint id = InteriorEntityIdAllocator.Base(0, 7) + 277u;
uint decodedYByte = (id & 0xFF000000u); // old scheme's Y read (byte-aligned)
Assert.NotEqual(0xC0000000u, decodedYByte); // sanity: still not a landblock-stab
Assert.True(id < InteriorEntityIdAllocator.Base(0, 8),
"277 entities must stay inside landblock Y=7's reserved id range");
}
[Fact]
public void MaxCounter_Is4095()
{
// Locks the documented capacity (12-bit counter) so a future edit
// that accidentally narrows it again gets caught here first.
Assert.Equal(0xFFFu, InteriorEntityIdAllocator.MaxCounter);
}
}