diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
index 4a7f8b44..1c702c3f 100644
--- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs
+++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs
@@ -8,12 +8,13 @@ namespace AcDream.App.Input;
/// GameWindow and ticked each frame from OnUpdate.
///
///
-/// Why is this its own class? The auto-entry has three independent
+/// Why is this its own class? The auto-entry has four independent
/// preconditions (live session reaches InWorld, the player
-/// entity has been streamed into the world dictionary, and the player
-/// movement controller is constructible) plus a manual-override path
+/// entity has been streamed into the world dictionary, the player
+/// movement controller is constructible, and the terrain under the
+/// spawn position has streamed in) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires —
-/// their choice wins). All four interact with each other in a way
+/// their choice wins). All five interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
/// fakes.
///
@@ -43,6 +44,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func _isLiveInWorld;
private readonly Func _isPlayerEntityPresent;
private readonly Func _isPlayerControllerReady;
+ private readonly Func _isSpawnGroundReady;
private readonly Action _enterPlayerMode;
private bool _armed;
@@ -60,6 +62,12 @@ public sealed class PlayerModeAutoEntry
/// PlayerMovementController is set up. Stays true once player mode
/// is established; the auto-entry's job is to flip it from false
/// to true exactly once.
+ /// True iff the terrain under the
+ /// player's spawn position has streamed into the physics engine.
+ /// #106 gate-2 (2026-06-09): entering player mode earlier integrates
+ /// gravity against an empty world and free-falls the player into the
+ /// void. Retail never has this state — it loads cells synchronously;
+ /// this hold is the async-streaming equivalent of that invariant.
/// Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
@@ -69,11 +77,13 @@ public sealed class PlayerModeAutoEntry
Func isLiveInWorld,
Func isPlayerEntityPresent,
Func isPlayerControllerReady,
+ Func isSpawnGroundReady,
Action enterPlayerMode)
{
_isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld));
_isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent));
_isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady));
+ _isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady));
_enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode));
}
@@ -107,6 +117,7 @@ public sealed class PlayerModeAutoEntry
if (!_isLiveInWorld()) return false;
if (!_isPlayerEntityPresent()) return false;
if (!_isPlayerControllerReady()) return false;
+ if (!_isSpawnGroundReady()) return false;
_armed = false;
_enterPlayerMode();
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 56ed5095..468abab3 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1001,6 +1001,13 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Net.WorldSession.State.InWorld,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
+ // #106 gate-2: hold player-mode entry until the terrain under
+ // the spawn position has streamed in — entering earlier
+ // integrates gravity against an empty world and free-falls
+ // the player into the void (retail loads cells synchronously;
+ // this is the async-streaming equivalent of that invariant).
+ isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
+ && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null,
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 4ae53df0..88eb096d 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -619,7 +619,40 @@ public static class CellTransit
// No interior cell contained the centre. Return the outdoor XY-column cell if
// it was a candidate, else stay on the current cell (retail leaves *result
// null → caller keeps curr_cell).
- return outdoorResult != 0u ? outdoorResult : currentCellId;
+ if (outdoorResult != 0u) return outdoorResult;
+
+ // ── #106 gate-2 escape hatch: bogus-indoor-claim recovery ──────────
+ // Restores the #83/A1.7 + #90 verification that lived in
+ // PhysicsEngine.ResolveCellId before the collide-then-pick rewrite
+ // moved membership here: an INDOOR current cell that IS hydrated but
+ // whose CellBSP no longer overlaps ANY part of the foot sphere is a
+ // bogus claim — a corrupt server save pairing an indoor cell with a
+ // position far outside it, or the player walked out through an
+ // unblocked gap. Keeping it wedges everything downstream: the BFS
+ // can't reach an exit portal from a cell the sphere isn't in (no
+ // candidates → frozen), ShadowObjectRegistry's #98 gate reads
+ // "indoor primary" (no object collision anywhere), and there's no
+ // wall BSP and no terrain (void fall). Demote to the outdoor column
+ // under the sphere centre (LandDefs global math — cross-block safe).
+ //
+ // Sphere-overlap (BSPQuery.SphereIntersectsCellBsp,
+ // pseudo_c:317666→:323267), NOT point-in: a doorway push-back leaves
+ // the centre a few cm outside while the sphere still overlaps — that
+ // must NOT demote (#90's ping-pong). A cell with no hydrated CellBSP
+ // cannot be verified — trust the claim (stale beats null while
+ // streaming hydrates).
+ if (currentLow >= 0x0100u && containingOutdoorId != 0u)
+ {
+ var cur = cache.GetCellStruct(currentCellId);
+ if (cur?.CellBSP?.Root is not null)
+ {
+ var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform);
+ if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius))
+ return containingOutdoorId;
+ }
+ }
+
+ return currentCellId;
}
private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres)
diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
index aa1463c5..86d4bb5d 100644
--- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
+++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs
@@ -24,6 +24,9 @@ public sealed class AutoEnterPlayerModeTests
public bool LiveInWorld;
public bool PlayerEntityPresent;
public bool PlayerControllerReady;
+ // Defaults TRUE: the hydration hold is not under test in the
+ // original K.2 cases (see SpawnGroundNotReady test for it).
+ public bool SpawnGroundReady = true;
public int EnteredCount;
public PlayerModeAutoEntry Build() =>
@@ -31,9 +34,37 @@ public sealed class AutoEnterPlayerModeTests
isLiveInWorld: () => LiveInWorld,
isPlayerEntityPresent: () => PlayerEntityPresent,
isPlayerControllerReady: () => PlayerControllerReady,
+ isSpawnGroundReady: () => SpawnGroundReady,
enterPlayerMode: () => EnteredCount++);
}
+ [Fact]
+ public void TryEnter_Armed_SpawnGroundNotReady_DoesNotFire()
+ {
+ // #106 gate-2 (2026-06-09): player physics must not start before
+ // the terrain under the spawn position has streamed in — entering
+ // earlier free-falls the player into the void (gravity integrates
+ // against an empty world; retail never has this state because it
+ // loads cells synchronously). The guard holds until the streaming
+ // pipeline registers the spawn landblock.
+ var s = new State
+ {
+ LiveInWorld = true, PlayerEntityPresent = true,
+ PlayerControllerReady = true, SpawnGroundReady = false,
+ };
+ var guard = s.Build();
+ guard.Arm();
+
+ Assert.False(guard.TryEnter());
+ Assert.Equal(0, s.EnteredCount);
+ Assert.True(guard.IsArmed);
+
+ // Terrain hydrates → fires on the next tick.
+ s.SpawnGroundReady = true;
+ Assert.True(guard.TryEnter());
+ Assert.Equal(1, s.EnteredCount);
+ }
+
[Fact]
public void TryEnter_NotArmed_DoesNotFire()
{
diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
index dd55654f..f5e78756 100644
--- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs
@@ -228,6 +228,103 @@ public class CellTransitFindCellSetTests
Assert.Equal(0xA9B40031u, containing);
}
+ // ──────────────────────────────────────────────────────────────────
+ // #106 gate-2 escape hatch — bogus indoor claim recovery.
+ // Restores the #83/A1.7 + #90 verification (formerly in ResolveCellId,
+ // lost in the collide-then-pick rewrite): a HYDRATED indoor current
+ // cell whose CellBSP no longer overlaps ANY part of the foot sphere is
+ // a bogus claim (corrupt server save pairing an indoor cell with a
+ // position far outside it, or walked out through an unblocked gap).
+ // Staying on it wedges everything: the BFS can't reach an exit portal
+ // from a cell the sphere isn't in (no outdoor candidates, membership
+ // frozen), GetNearbyObjects' #98 gate reads "indoor primary" (no
+ // object collision), no wall BSP and no terrain (void fall). The pick
+ // must demote to the outdoor column under the sphere centre.
+ // Sphere-overlap (not point-in) preserves the #90 doorway-pushback
+ // hysteresis.
+ // ──────────────────────────────────────────────────────────────────
+
+ /// Cell whose CellBSP half-space is x ≥ 0 (cell-local).
+ private static CellPhysics MakeCellWithBoundedBsp(Matrix4x4 worldTransform)
+ {
+ Matrix4x4.Invert(worldTransform, out var inv);
+ return new CellPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode
+ {
+ SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ }
+ };
+ }
+
+ [Fact]
+ public void IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn()
+ {
+ // The gate-2 live wedge shape: claimed cell 0xA9B40150, sphere far
+ // outside its volume (x = -10, fully behind the x≥0 half-space).
+ // The BFS finds no portals (sphere nowhere near them), so no outdoor
+ // candidates exist — pre-fix this returned the bogus claim forever.
+ // Expected: the global outdoor column under the centre. x=-10 is one
+ // cell WEST of the A9B4 block edge → block 0xA8B4, lcoord (1351,1440)
+ // → cell (7,0) → low 0x39.
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity));
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40150u,
+ out _);
+
+ Assert.Equal(0xA8B40039u, containing);
+ }
+
+ [Fact]
+ public void IndoorSeed_SphereStraddlesCellBoundary_StaysCurrent()
+ {
+ // #90 hysteresis guard: centre just outside the half-space (x=-0.3)
+ // but the 0.5 radius still reaches back in → sphere OVERLAPS → the
+ // claim is legitimate (doorway push-back shape) → NO demotion.
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity));
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(-0.3f, 12f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40150u,
+ out _);
+
+ Assert.Equal(0xA9B40150u, containing);
+ }
+
+ [Fact]
+ public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent()
+ {
+ // Stale-beats-null while streaming hydrates: a registered cell with
+ // no CellBSP yet cannot be verified — trust the claim (no demotion).
+ Matrix4x4.Invert(Matrix4x4.Identity, out var inv);
+ var cellNoBsp = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary(),
+ };
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp);
+
+ uint containing = CellTransit.FindCellSet(
+ cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f,
+ currentCellId: 0xA9B40150u,
+ out _);
+
+ Assert.Equal(0xA9B40150u, containing);
+ }
+
// ──────────────────────────────────────────────────────────────────
// Membership hysteresis — the R1-flap root cause.
// Retail CObjCell::find_cell_list adds the CURRENT cell at index 0