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:
parent
f6b054b7d7
commit
e651cb6dd1
3 changed files with 165 additions and 7 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue