diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 1deed49..4a3b8e8 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -1166,6 +1166,92 @@ public sealed class Transition
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
+ ///
+ /// Indoor walking Phase 2 follow-up (2026-05-19). Finds the walkable floor
+ /// polygon directly under within
+ /// . Used when the indoor cell-BSP query
+ /// returns OK (no wall collision) — we need to provide a walkable contact
+ /// plane from the cell's geometry instead of falling through to outdoor
+ /// terrain (which is below the cell floor due to the +0.02f Z-bump
+ /// applied at GameWindow.BuildInteriorEntitiesForStreaming).
+ ///
+ ///
+ /// Iterates physics polygons; selects
+ /// the one with the most upward-facing normal (Z >= 0.6664 = walkable
+ /// slope threshold matching retail's WalkableSlopeMin) whose XY projection
+ /// contains the player's local foot XY. Returns the polygon's plane +
+ /// vertices in WORLD space for the ValidateWalkable call.
+ ///
+ ///
+ ///
+ /// Returns false if no walkable floor poly is found under the
+ /// player. The caller falls through to outdoor terrain in that case
+ /// (defensive backstop — should not normally happen inside a sealed cell).
+ ///
+ ///
+ internal static bool TryFindIndoorWalkablePlane(
+ CellPhysics cellPhysics,
+ Vector3 localFootCenter,
+ out System.Numerics.Plane worldPlane,
+ out Vector3[] worldVertices,
+ out uint hitPolyId)
+ {
+ worldPlane = default;
+ worldVertices = System.Array.Empty();
+ hitPolyId = 0;
+
+ foreach (var (id, poly) in cellPhysics.Resolved)
+ {
+ // Walkable slope threshold matches retail WalkableSlopeMin (0.6664...)
+ // and our existing TerrainSurface.WalkableSlopeMin check.
+ if (poly.Plane.Normal.Z < 0.6664f) continue;
+ if (poly.Vertices is null || poly.Vertices.Length < 3) continue;
+
+ // Point-in-polygon test in XY (ignore Z). Ray-casting even-odd rule.
+ if (!PointInPolygonXY(localFootCenter, poly.Vertices)) continue;
+
+ // Found a floor poly under the player. Transform plane + vertices
+ // to world space.
+ var worldNormal = Vector3.TransformNormal(poly.Plane.Normal, cellPhysics.WorldTransform);
+ worldNormal = Vector3.Normalize(worldNormal);
+ // Take vertex 0, transform to world, recompute D so the plane
+ // equation normal·p + D = 0 holds at the world-space vertex.
+ var worldV0 = Vector3.Transform(poly.Vertices[0], cellPhysics.WorldTransform);
+ float worldD = -Vector3.Dot(worldNormal, worldV0);
+ worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+
+ worldVertices = new Vector3[poly.Vertices.Length];
+ for (int i = 0; i < poly.Vertices.Length; i++)
+ worldVertices[i] = Vector3.Transform(poly.Vertices[i], cellPhysics.WorldTransform);
+
+ hitPolyId = id;
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Point-in-polygon test in the XY plane (ignores Z). Standard ray-casting
+ /// even-odd rule. Works for convex and concave polygons.
+ ///
+ internal static bool PointInPolygonXY(Vector3 point, Vector3[] vertices)
+ {
+ bool inside = false;
+ int n = vertices.Length;
+ for (int i = 0, j = n - 1; i < n; j = i++)
+ {
+ var vi = vertices[i];
+ var vj = vertices[j];
+ if (((vi.Y > point.Y) != (vj.Y > point.Y)) &&
+ (point.X < (vj.X - vi.X) * (point.Y - vi.Y) / (vj.Y - vi.Y) + vi.X))
+ {
+ inside = !inside;
+ }
+ }
+ return inside;
+ }
+
///
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
/// Indoor BSP collision is deferred to Task 6c.
@@ -1255,6 +1341,39 @@ public sealed class Transition
ci.CollidedWithEnvironment = true;
return cellState;
}
+
+ // ── Synthesize indoor walkable contact plane ──────────────
+ // Indoor walking Phase 2 follow-up (2026-05-19). When the BSP
+ // returns OK (no wall collision), the player is standing on a
+ // floor poly inside the cell. We must NOT fall through to
+ // outdoor terrain (SampleTerrainWalkable) — the outdoor terrain
+ // Z is below the indoor floor due to the +0.02f Z-bump applied
+ // for render z-fight prevention. ValidateWalkable would then see
+ // the player 0.5m above the outdoor plane → marks them as
+ // airborne → walkable=False → falling animation, never recovers.
+ //
+ // Retail: CEnvCell::find_env_collisions returns from the cell
+ // branch with the cell's walkable plane set — no fall-through
+ // to terrain.
+ if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+ {
+ return ValidateWalkable(
+ footCenter,
+ sphereRadius,
+ indoorPlane,
+ isWater: false,
+ waterDepth: 0f,
+ cellId: sp.CheckCellId,
+ walkableVertices: indoorVertices);
+ }
+ // If no walkable floor was found under the player indoors
+ // (rare — cell with only walls/ceiling), fall through to
+ // outdoor terrain as a defensive backstop. Indoor walking
+ // will report walkable=False until the player moves over a
+ // cell with a proper floor poly.
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
new file mode 100644
index 0000000..1a455c6
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.Enums;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Unit tests for and
+/// .
+///
+/// Indoor walking Phase 2 follow-up (2026-05-19): these helpers synthesize
+/// a walkable contact plane from cell floor polys so the resolver does not
+/// fall through to outdoor terrain when the player is standing indoors.
+///
+public class IndoorWalkablePlaneTests
+{
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Builds a CellPhysics with a single upward-facing floor polygon
+ /// (a 10×10 square in the XY plane at local Z=0), plus identity transforms.
+ ///
+ private static CellPhysics BuildCellWithFloor(float floorZ = 0f)
+ {
+ var verts = new[]
+ {
+ new Vector3(-5f, -5f, floorZ),
+ new Vector3( 5f, -5f, floorZ),
+ new Vector3( 5f, 5f, floorZ),
+ new Vector3(-5f, 5f, floorZ),
+ };
+ var normal = new Vector3(0f, 0f, 1f); // straight up
+ float D = -Vector3.Dot(normal, verts[0]); // = -floorZ
+
+ var floorPoly = new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new Plane(normal, D),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+
+ return new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary { [0] = floorPoly },
+ };
+ }
+
+ // -----------------------------------------------------------------------
+ // TryFindIndoorWalkablePlane
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue()
+ {
+ var cell = BuildCellWithFloor(floorZ: 0f);
+ var localFoot = new Vector3(0f, 0f, 0.5f); // centred over the 10×10 square
+
+ bool found = Transition.TryFindIndoorWalkablePlane(
+ cell, localFoot,
+ out var plane, out var verts, out uint polyId);
+
+ Assert.True(found);
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp()
+ {
+ var cell = BuildCellWithFloor(floorZ: 0f);
+ var localFoot = new Vector3(0f, 0f, 0.5f);
+
+ Transition.TryFindIndoorWalkablePlane(
+ cell, localFoot, out var plane, out _, out _);
+
+ // The floor's normal must point up (Z close to 1).
+ Assert.True(plane.Normal.Z > 0.99f,
+ $"Expected plane.Normal.Z > 0.99, got {plane.Normal.Z}");
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneAtFloorZ()
+ {
+ const float floorZ = 2.5f;
+ var cell = BuildCellWithFloor(floorZ);
+ var localFoot = new Vector3(0f, 0f, floorZ + 0.5f);
+
+ Transition.TryFindIndoorWalkablePlane(
+ cell, localFoot, out var plane, out _, out _);
+
+ // With identity transform and an upward normal, plane.D = -floorZ.
+ // The plane equation: normal·p + D = 0 → p.Z = floorZ when normal=(0,0,1).
+ Assert.True(MathF.Abs(plane.D - (-floorZ)) < 1e-4f,
+ $"Expected plane.D ≈ {-floorZ}, got {plane.D}");
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_PlayerOutsidePolygonXY_ReturnsFalse()
+ {
+ var cell = BuildCellWithFloor();
+ // XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes).
+ var localFoot = new Vector3(20f, 20f, 0.5f);
+
+ bool found = Transition.TryFindIndoorWalkablePlane(
+ cell, localFoot, out _, out _, out _);
+
+ Assert.False(found);
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_NoWalkablePolys_ReturnsFalse()
+ {
+ // A polygon whose normal points sideways (wall) — normal.Z < 0.6664.
+ var wallPoly = new ResolvedPolygon
+ {
+ Vertices = new[] { Vector3.Zero, Vector3.UnitY, Vector3.UnitZ },
+ Plane = new Plane(new Vector3(1f, 0f, 0f), 0f), // normal.Z = 0
+ NumPoints = 3,
+ SidesType = CullMode.None,
+ };
+ var cell = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary { [1] = wallPoly },
+ };
+
+ bool found = Transition.TryFindIndoorWalkablePlane(
+ cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
+
+ Assert.False(found);
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse()
+ {
+ var cell = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ };
+
+ bool found = Transition.TryFindIndoorWalkablePlane(
+ cell, new Vector3(0f, 0f, 0.5f), out _, out _, out _);
+
+ Assert.False(found);
+ }
+
+ [Fact]
+ public void TryFindIndoorWalkablePlane_WithWorldTranslation_PlaneInWorldSpace()
+ {
+ // Cell is translated 100 units in X and 200 units in Y.
+ var translation = Matrix4x4.CreateTranslation(100f, 200f, 94f);
+ Matrix4x4.Invert(translation, out var inv);
+
+ var localVerts = new[]
+ {
+ new Vector3(-5f, -5f, 0f),
+ new Vector3( 5f, -5f, 0f),
+ new Vector3( 5f, 5f, 0f),
+ new Vector3(-5f, 5f, 0f),
+ };
+ var floorPoly = new ResolvedPolygon
+ {
+ Vertices = localVerts,
+ Plane = new Plane(new Vector3(0f, 0f, 1f), 0f),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ };
+ var cell = new CellPhysics
+ {
+ WorldTransform = translation,
+ InverseWorldTransform = inv,
+ Resolved = new Dictionary { [0] = floorPoly },
+ };
+
+ // The player's local foot is at (0,0,0.5) in local space.
+ var localFoot = new Vector3(0f, 0f, 0.5f);
+
+ bool found = Transition.TryFindIndoorWalkablePlane(
+ cell, localFoot, out var plane, out var worldVerts, out _);
+
+ Assert.True(found);
+ // World normal should still be (0,0,1).
+ Assert.True(plane.Normal.Z > 0.99f);
+ // World vertex[0] should be at local (-5,-5,0) + translation = (95, 195, 94).
+ Assert.True(MathF.Abs(worldVerts[0].X - 95f) < 1e-3f);
+ Assert.True(MathF.Abs(worldVerts[0].Y - 195f) < 1e-3f);
+ Assert.True(MathF.Abs(worldVerts[0].Z - 94f) < 1e-3f,
+ $"Expected worldVerts[0].Z ≈ 94, got {worldVerts[0].Z}");
+ }
+
+ // -----------------------------------------------------------------------
+ // PointInPolygonXY
+ // -----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData( 0f, 0f, true)] // centre
+ [InlineData( 4f, 4f, true)] // near corner, inside
+ [InlineData( 5f, 5f, false)] // on the corner — outside by convention
+ [InlineData(10f, 0f, false)] // clearly outside
+ [InlineData(-4f, -4f, true)] // near opposite corner, inside
+ public void PointInPolygonXY_UnitSquare(float px, float py, bool expected)
+ {
+ var square = new[]
+ {
+ new Vector3(-5f, -5f, 0f),
+ new Vector3( 5f, -5f, 0f),
+ new Vector3( 5f, 5f, 0f),
+ new Vector3(-5f, 5f, 0f),
+ };
+ bool result = Transition.PointInPolygonXY(new Vector3(px, py, 99f), square);
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void PointInPolygonXY_IgnoresZ()
+ {
+ // Same XY, different Z — should still be inside.
+ var square = new[]
+ {
+ new Vector3(-5f, -5f, 0f),
+ new Vector3( 5f, -5f, 0f),
+ new Vector3( 5f, 5f, 0f),
+ new Vector3(-5f, 5f, 0f),
+ };
+ // Point has the same XY as the inside case but a very different Z.
+ bool atLowZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, -1000f), square);
+ bool atHighZ = Transition.PointInPolygonXY(new Vector3(0f, 0f, 1000f), square);
+
+ Assert.True(atLowZ);
+ Assert.True(atHighZ);
+ }
+}