diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index ec5f0fe8..784f2d7e 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -66,6 +66,20 @@ geometry goldens (0xA9B40031 → 0xA9B30038/0xA9B30034) and a non-anchor-frame
northbound return. Pseudocode + decomp-artifact notes:
`docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md`.
+**Gate attempt 1 (2026-06-09):** outside-looking-in correct + running distortion gone,
+but the walk was sabotaged by a PRE-EXISTING bug the corrective ACE teleport exposed:
+the teleport-arrival snap (`GameWindow.cs:4869`) committed a BARE indoor cell id
+(`0x0000013F`) from the legacy `PhysicsEngine.Resolve`, which returned low-word-only
+cell ids on every computed exit (the L.2e bare-low-byte finding from 2026-05-12).
+A bare indoor id wedges the chain: no wall BSP (`GetCellStruct` miss), the #98
+`b3ce505` gate reads "indoor primary" and skips the outdoor object sweep (NO object
+collision anywhere), and the pick can never re-resolve a malformed id (probe log:
+2 `[cell-transit]` lines all session, both teleports). Follow-up fix: `Resolve` now
+returns the matched landblock's full prefixed id on every computed exit; the
+unmasked `CellId < 0x100` test assertion that codified the bare behavior fixed.
+Prefix survival before this was a race artifact — `Resolve` only preserved the full
+id when the landblock wasn't streamed in yet (passthrough exit).
+
**Description:** Walking outdoors across a landblock boundary does NOT update the player's
outdoor cell: `playerCell` stays pinned to the last cell of the previous landblock,
indefinitely. Every downstream consumer degrades: entering any building in the new landblock
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 4cc5005a..95553b6c 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -463,7 +463,14 @@ public sealed class PhysicsEngine
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// Find the landblock this candidate position falls in.
+ // #106 follow-up (2026-06-09): capture its high-16 prefix — every
+ // computed cell id below is returned FULL (lbPrefix | low). The old
+ // bare-low-word returns wedged the membership chain whenever a caller
+ // committed them (the teleport-arrival snap wrote 0x0000013F: an
+ // unresolvable indoor id → no wall BSP, #98 gate reads "indoor
+ // primary" and kills the outdoor object sweep → no collision at all).
LandblockPhysics? physics = null;
+ uint lbPrefix = 0u;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
@@ -472,6 +479,7 @@ public sealed class PhysicsEngine
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
{
physics = lb;
+ lbPrefix = kvp.Key & 0xFFFF0000u;
break;
}
}
@@ -637,7 +645,7 @@ public sealed class PhysicsEngine
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
- targetCellId,
+ lbPrefix | (targetCellId & 0xFFFFu),
IsOnGround: true);
}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index 9ee570c7..f6850e6e 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -365,12 +365,65 @@ public class PhysicsEngineTests
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
stepUpHeight: 10f);
- // Should transition back to outdoor.
- Assert.True(result.CellId < 0x0100u);
+ // Should transition back to outdoor. (#106 follow-up: masked compare —
+ // Resolve now returns FULL prefixed cell ids; the old unmasked
+ // `CellId < 0x0100` assertion codified the bare-low-word bug.)
+ Assert.True((result.CellId & 0xFFFFu) < 0x0100u);
+ Assert.Equal(0xA9B40000u, result.CellId & 0xFFFF0000u);
Assert.Equal(50f, result.Position.Z, precision: 1);
Assert.True(result.IsOnGround);
}
+ ///
+ /// #106 follow-up (2026-06-09): the live boundary-walk gate was sabotaged by
+ /// the teleport-arrival snap (GameWindow.cs:4869) receiving a BARE indoor
+ /// cell id from Resolve (`0x0000013F`). A bare indoor id wedges the whole
+ /// membership chain: GetCellStruct misses (no wall BSP), the #98 gate reads
+ /// "indoor primary" and skips the outdoor object sweep (no collision with
+ /// anything), and FindCellSet can never re-resolve a malformed id. Resolve
+ /// MUST return the matched landblock's full 32-bit cell id on every
+ /// computed exit — the same convention its own inputs use.
+ ///
+ [Fact]
+ public void Resolve_IndoorStay_ReturnsFullPrefixedCellId()
+ {
+ var engine = new PhysicsEngine();
+ var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
+
+ var cellVerts = new Dictionary
+ {
+ [0] = new(40f, 40f, 55f),
+ [1] = new(60f, 40f, 55f),
+ [2] = new(60f, 60f, 55f),
+ [3] = new(40f, 60f, 55f),
+ };
+ var cellPolys = new List> { new() { 0, 1, 2, 3 } };
+ var cell = new CellSurface(0x0100, cellVerts, cellPolys);
+
+ engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // The teleport shape: full indoor cell id in, zero delta (pure snap).
+ var result = engine.Resolve(
+ new Vector3(50f, 50f, 55f), cellId: 0xA9B40100u, delta: Vector3.Zero,
+ stepUpHeight: 5f);
+
+ Assert.Equal(0xA9B40100u, result.CellId);
+ }
+
+ [Fact]
+ public void Resolve_OutdoorStay_ReturnsFullPrefixedCellId()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+
+ var result = engine.Resolve(
+ new Vector3(96f, 96f, 50f), cellId: 0xA9B40029u, delta: new Vector3(1f, 0f, 0f),
+ stepUpHeight: 2f);
+
+ // (97, 96) is over grid (4, 4) → low = 4*8+4+1 = 0x25, prefixed.
+ Assert.Equal(0xA9B40025u, result.CellId);
+ }
+
[Fact]
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
{