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,49 @@
namespace AcDream.Core.World;
/// <summary>
/// Packs a stable, collision-free id base for a landblock's dat-hydrated
/// EnvCell-interior entities.
///
/// <para>
/// <b>Why this exists (#190, 2026-07-09).</b> The id space is
/// <c>0x4X XXXXX</c> — the top NIBBLE fixed at 4, distinguishing interior
/// statics from live weenies (ids &lt; 0x40000000), procedural scenery
/// (0x80000000+, bit 31 set), and landblock stabs (0xC0000000+). Nothing
/// decodes a landblock's X/Y back out of an entity id — every consumer
/// (GameWindow.cs's <c>_isOutdoorMesh</c>/<c>_isLandblockStab</c>
/// classification) reads only a THRESHOLD or a full-byte PREFIX — so the
/// internal field widths below are free to change without touching any
/// consumer.
/// </para>
///
/// <para>
/// 28 remaining bits split X(8) / Y(8) / counter(12). X and Y stay at a
/// full byte each to match AC's 0-255 landblock grid — shrinking either
/// reintroduces #119's cross-landblock aliasing (two DIFFERENT landblocks
/// sharing one id space). The prior split (X8/Y8/counter8, "0x40XXYY##")
/// fixed #119 but left only 256 ids for ONE landblock's entire interior
/// static population. The Town Network hub (205 cells, one landblock)
/// already reached 248 before the #79/#93 A7.L1 light-carrier hydration
/// fix, which pushed it to 277 — PAST the 8-bit boundary, silently
/// aliasing into the NEXT landblock's reserved Y-byte (id 0x40000815 read
/// back as landblock Y=0x08, not the true Y=0x07): the exact #119 bug,
/// reincarnated by entity COUNT instead of a computation bug. Shrinking
/// the fixed prefix from a full byte (0x40) to its top nibble (0x4_) frees
/// 4 bits for the counter (8→12 bits, 256→4096 capacity) — ~15x headroom
/// over the observed count.
/// </para>
/// </summary>
public static class InteriorEntityIdAllocator
{
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
/// more interior entities than this would alias into the next Y-slot —
/// exactly the #190 bug, just at a higher threshold. Callers should log
/// loudly (never silently wrap) if this is ever exceeded.</summary>
public const uint MaxCounter = 0xFFFu;
/// <summary>The first id in this landblock's reserved range (counter=0).
/// Add a counter in <c>[0, MaxCounter]</c> to allocate a unique entity id
/// within the landblock.</summary>
public static uint Base(uint landblockX, uint landblockY)
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
}