diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 05354644..078e8ea5 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -32,13 +32,23 @@ public sealed class PhysicsEngine
public int LandblockCount => _landblocks.Count;
///
- /// Returns true iff a landblock with this id has been registered via
- /// . Because registers
- /// terrain + collision cells + portals atomically, ContainsKey implies the
- /// collision mesh is present and resolvable.
+ /// Returns true iff the landblock CONTAINING the given id has been registered via
+ /// . Because registers terrain +
+ /// collision cells + portals atomically, a hit implies the collision mesh is present
+ /// and resolvable.
+ ///
+ /// The argument may be any id within the landblock — a full cell id (low 16 = cell
+ /// index), the landblock-prefix form (low 16 = 0x0000, as the teleport gate passes via
+ /// destCell & 0xFFFF0000), or the dat-id storage form. The low 16 bits are
+ /// normalized to the canonical landblock key (0xFFFF) that streaming stores under
+ /// ( ORs in 0xFFFF),
+ /// so all three forms resolve. The earlier raw ContainsKey(id & 0xFFFF0000)
+ /// never matched the 0xFFFF-terminated stored key, leaving the outdoor teleport gate
+ /// permanently NotReady (2026-06-21 #145 apparatus re-test).
+ ///
///
public bool IsLandblockLoaded(uint landblockId) =>
- _landblocks.ContainsKey(landblockId);
+ _landblocks.ContainsKey((landblockId & 0xFFFF0000u) | 0xFFFFu);
///
/// Cell-based spatial index for static object collision.
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
index 727eeccf..d32b5a6d 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
@@ -54,4 +54,37 @@ public class PhysicsEngineIsLandblockLoadedTests
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
+
+ // Regression for the Slice 2 key-mismatch bug (#145 apparatus re-test, 2026-06-21):
+ // landblocks are stored under the EncodeLandblockId form (low 16 = 0xFFFF), but the
+ // teleport gate (TeleportArrivalReadiness) queries with (destCell & 0xFFFF0000) (low
+ // 16 = 0x0000). A raw ContainsKey never matched, so the outdoor gate was permanently
+ // NotReady and every outdoor teleport timed out at 10 s. IsLandblockLoaded must
+ // normalize so ANY id within the landblock resolves to the stored entry.
+ [Theory]
+ [InlineData(0xA9B40000u)] // landblock-prefix form — exactly what the gate passes (destCell & 0xFFFF0000)
+ [InlineData(0xA9B4000Du)] // an outdoor cell id within the landblock
+ [InlineData(0xA9B40143u)] // an indoor cell id within the landblock
+ [InlineData(0xA9B4FFFFu)] // the dat-id storage form
+ public void IsLandblockLoaded_NormalizesLow16_FindsLandblockForAnyContainedId(uint queryId)
+ {
+ var engine = new PhysicsEngine();
+ engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
+ Array.Empty(), Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ Assert.True(engine.IsLandblockLoaded(queryId));
+ }
+
+ [Fact]
+ public void IsLandblockLoaded_DifferentLandblockPrefix_ReturnsFalse()
+ {
+ var engine = new PhysicsEngine();
+ engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
+ Array.Empty(), Array.Empty(),
+ worldOffsetX: 0f, worldOffsetY: 0f);
+
+ // A different landblock prefix must NOT match, even in the 0x..0000 query form.
+ Assert.False(engine.IsLandblockLoaded(0xA9B50000u));
+ }
}