diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index df0af71..9f2be66 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1152,14 +1152,11 @@ public static class BSPQuery /// /// /// - /// Out-of-band "find a walkable plane indoors" entry point for callers - /// that genuinely need to query a cell's walkable floor (spawn-placement - /// validation, teleport-target verification, future debug overlays). - /// NOT called from the per-frame physics resolver — the original - /// per-frame caller (TryFindIndoorWalkablePlane) was deleted 2026-05-20 - /// because retail's BSPTREE::find_collisions does NOT re-synthesize the - /// ContactPlane on the OK path. The wrapper is kept here as the - /// underlying retail-faithful walkable-finder API. + /// Intended call site: indoor walkable-plane synthesis in + /// Transition.TryFindIndoorWalkablePlane when the indoor cell-BSP + /// collision returns OK (no wall hit) and the resolver still needs a + /// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path + /// () and does not use this. /// /// /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index a1cc43c..c941f67 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1266,6 +1266,120 @@ public sealed class Transition // Environment collision — outdoor terrain // ----------------------------------------------------------------------- + /// + /// Synthesize the indoor walkable contact plane for the player's current + /// position when the cell BSP returns OK (no wall collision). + /// + /// + /// Routes through the retail-faithful BSP walkable-finder + /// () — which traverses the cell + /// PhysicsBSP and picks the polygon closest to the foot along the up vector. + /// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a + /// stop-gap; that scan picked the wrong floor whenever two polygons + /// overlapped in XY at different Z (cellars, 2nd floors, balconies). + /// + /// + /// + /// 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). + /// + /// + /// + /// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793), + /// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006), + /// CPolygon::adjust_sphere_to_plane (:322032). + /// + /// + internal bool TryFindIndoorWalkablePlane( + CellPhysics cellPhysics, + Vector3 localFootCenter, + float sphereRadius, + out System.Numerics.Plane worldPlane, + out Vector3[] worldVertices, + out uint hitPolyId) + { + worldPlane = default; + worldVertices = System.Array.Empty(); + hitPolyId = 0; + + if (cellPhysics.BSP?.Root is null) return false; + + // Build foot sphere in cell-local space. Caller passes localFootCenter + // already transformed into cell-local space and the resolver's + // foot-sphere radius. + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = localFootCenter, + Radius = sphereRadius, + }; + + // Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads + // path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For + // "standing here, find my floor" we want the walkability slope + // threshold FloorZ. The outer resolver may have set it to LandingZ + // (airborne→ground transition) or another value; we must not leak our + // change back to the resolver. try/finally so an exception inside + // FindWalkableSphere doesn't leak the modified state. + float savedWalkableAllowance = this.SpherePath.WalkableAllowance; + this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ; + + ResolvedPolygon? hitPoly = null; + ushort hitId = 0; + Vector3 adjustedCenter; + bool found; + + try + { + found = BSPQuery.FindWalkableSphere( + cellPhysics.BSP.Root, + cellPhysics.Resolved, + this, + localSphere, + INDOOR_WALKABLE_PROBE_DISTANCE, + Vector3.UnitZ, // local Z is up for indoor cells (identity transform) + out hitPoly, + out hitId, + out adjustedCenter); + } + finally + { + this.SpherePath.WalkableAllowance = savedWalkableAllowance; + } + + // adjustedCenter (sphere slid onto polygon plane) is intentionally + // discarded — ValidateWalkable recomputes contact geometry from the + // world-space plane + foot position, consistent with the outdoor terrain + // path (SampleTerrainWalkable returns only plane + vertices, no adjusted + // sphere). The local is held only to satisfy the out param. + + if (!found || hitPoly is null) return false; + + // Transform hit polygon's plane + vertices to world space. Math is + // unchanged from the previous TryFindIndoorWalkablePlane implementation. + var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform); + worldNormal = Vector3.Normalize(worldNormal); + var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform); + float worldD = -Vector3.Dot(worldNormal, worldV0); + worldPlane = new System.Numerics.Plane(worldNormal, worldD); + + worldVertices = new Vector3[hitPoly.Vertices.Length]; + for (int i = 0; i < hitPoly.Vertices.Length; i++) + worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform); + + hitPolyId = hitId; + return true; + } + + /// + /// Downward probe distance used by + /// when scanning for the indoor walkable contact plane. 50 cm. + /// Larger than the +0.02f cell-origin Z-bump and larger than any realistic + /// step riser; smaller than a full cell height so we don't reach through + /// a thin floor into the cell above/below. + /// + private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f; + /// /// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic. /// Indoor BSP collision is deferred to Task 6c. @@ -1389,22 +1503,59 @@ public sealed class Transition return cellState; } - // Indoor BSP returned OK — no wall collision. ContactPlane - // is RETAINED from the prior tick's seed - // (PhysicsEngine.ResolveWithTransition:583, the - // init_contact_plane equivalent) OR refreshed by Path 3 - // step-down / Path 4 land if those fired this tick. Either - // way, no synthesis is needed here — matches retail's - // BSPTREE::find_collisions OK path - // (acclient_2013_pseudo_c.txt:323938). + // ── 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. // - // Do NOT fall through to outdoor terrain backstop: the - // player is in an indoor cell, and the outdoor terrain - // Z is below the indoor floor by ~0.02m (the render Z-bump), - // which would mark the player as airborne and trigger the - // falling-animation stuck symptom (the original Bug A). - // 2026-05-20 slice 2 of indoor ContactPlane retention. - return TransitionState.OK; + // Retail: CEnvCell::find_env_collisions returns from the cell + // branch with the cell's walkable plane set — no fall-through + // to terrain. + bool walkableHit = TryFindIndoorWalkablePlane( + cellPhysics, localCenter, sphereRadius, + out var indoorPlane, + out var indoorVertices, + out uint hitPolyId); + + if (PhysicsDiagnostics.ProbeIndoorBspEnabled) + { + if (walkableHit) + { + // dz = signed gap between foot and synthesized plane. + // Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes) + // gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z + float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z; + Console.WriteLine(System.FormattableString.Invariant( + $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=HIT poly=0x{hitPolyId:X4} wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}")); + } + else + { + Console.WriteLine(System.FormattableString.Invariant( + $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) probe={INDOOR_WALKABLE_PROBE_DISTANCE:F2} result=MISS")); + } + } + + if (walkableHit) + { + 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..75f136e --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs @@ -0,0 +1,291 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Unit tests for . +/// +/// Indoor walking Phase 2 follow-up (2026-05-19): the helper synthesizes +/// a walkable contact plane from cell floor polys so the resolver does not +/// fall through to outdoor terrain when the player is standing indoors. +/// +/// Task 3 (2026-05-19): refactored to route through BSPQuery.FindWalkableSphere. +/// Fixtures now include a PhysicsBSPTree with a Leaf node listing all polygon ids, +/// and calls pass sphereRadius explicitly. PointInPolygonXY tests removed since +/// that helper was deleted (it was the dead linear-scan body). +/// +public class IndoorWalkablePlaneTests +{ + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /// + /// Build a BSP Leaf node that lists the given polygon ids, with a bounding + /// sphere large enough to always contain the test geometry. + /// + private static PhysicsBSPTree BuildLeafBsp(IEnumerable polyIds, + Vector3 center, float radius) + { + var node = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = center, Radius = radius }, + }; + foreach (var id in polyIds) + node.Polygons.Add(id); + return new PhysicsBSPTree { Root = node }; + } + + /// + /// 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 + /// and a BSP leaf that covers all polygons. + /// + 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, + }; + + var resolved = new Dictionary { [0] = floorPoly }; + var bsp = BuildLeafBsp(new ushort[] { 0 }, new Vector3(0f, 0f, floorZ), 10f); + + return new CellPhysics + { + BSP = bsp, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = resolved, + }; + } + + // ----------------------------------------------------------------------- + // TryFindIndoorWalkablePlane + // ----------------------------------------------------------------------- + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_ReturnsTrue() + { + var cell = BuildCellWithFloor(floorZ: 0f); + var transition = new Transition(); + // Foot sphere centre at Z=0.4, radius=0.48 → overlaps floor at Z=0. + var localFoot = new Vector3(0f, 0f, 0.4f); + + bool found = transition.TryFindIndoorWalkablePlane( + cell, localFoot, sphereRadius: 0.48f, + out _, out _, out _); + + Assert.True(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_PlayerDirectlyOverFloor_PlaneNormalIsUp() + { + var cell = BuildCellWithFloor(floorZ: 0f); + var transition = new Transition(); + var localFoot = new Vector3(0f, 0f, 0.4f); + + transition.TryFindIndoorWalkablePlane( + cell, localFoot, sphereRadius: 0.48f, + 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 transition = new Transition(); + // Foot sphere overlaps floor: centre at floorZ + 0.4, radius=0.48 → dist=0.4 < 0.48. + var localFoot = new Vector3(0f, 0f, floorZ + 0.4f); + + transition.TryFindIndoorWalkablePlane( + cell, localFoot, sphereRadius: 0.48f, + 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(); + var transition = new Transition(); + // XY = (20, 20) is far outside the 10×10 square (-5..5 in both axes). + var localFoot = new Vector3(20f, 20f, 0.4f); + + bool found = transition.TryFindIndoorWalkablePlane( + cell, localFoot, sphereRadius: 0.48f, + out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_NoBsp_ReturnsFalse() + { + // CellPhysics without a BSP → BSP?.Root is null → early return false. + var cell = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + var transition = new Transition(); + + bool found = transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f, + out _, out _, out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_WallPolyInBsp_ReturnsFalse() + { + // A polygon with a horizontal normal (Z = 0) is a wall, not a floor. + // walkable_hits_sphere rejects it: dp = dot(UnitZ, (0,1,0)) = 0 <= FloorZ. + // Regression coverage for the previous NoWalkablePolys_ReturnsFalse intent + // (the renamed NoBsp_ReturnsFalse only covers the null-BSP early-return). + Vector3[] wallVerts = + { + new Vector3(0f, 0f, 0f), + new Vector3(1f, 0f, 0f), + new Vector3(1f, 0f, 1f), + new Vector3(0f, 0f, 1f), + }; + var resolved = new Dictionary + { + [0] = new ResolvedPolygon + { + Vertices = wallVerts, + Plane = new Plane(new Vector3(0f, 1f, 0f), 0f), // wall facing +Y + NumPoints = 4, + SidesType = CullMode.None, + }, + }; + + var center = new Vector3(0.5f, 0f, 0.5f); + var bsp = BuildLeafBsp(new ushort[] { 0 }, center, 2f); + + var cell = new CellPhysics + { + BSP = bsp, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = resolved, + }; + + var transition = new Transition(); + transition.SpherePath.WalkInterp = 1.0f; + + // Foot sphere positioned to overlap the wall's plane (|Y - 0| = 0 < radius 0.48). + bool found = transition.TryFindIndoorWalkablePlane( + cell, + localFootCenter: new Vector3(0.5f, 0f, 0.5f), + sphereRadius: 0.48f, + out _, + out _, + out _); + + Assert.False(found); + } + + [Fact] + public void TryFindIndoorWalkablePlane_EmptyResolved_ReturnsFalse() + { + // BSP leaf exists but references no polygons → FindWalkableSphere returns false. + var bsp = BuildLeafBsp(System.Array.Empty(), Vector3.Zero, 10f); + var cell = new CellPhysics + { + BSP = bsp, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary(), + }; + var transition = new Transition(); + + bool found = transition.TryFindIndoorWalkablePlane( + cell, new Vector3(0f, 0f, 0.4f), sphereRadius: 0.48f, + 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 resolved = new Dictionary { [0] = floorPoly }; + var bsp = BuildLeafBsp(new ushort[] { 0 }, Vector3.Zero, 10f); + + var cell = new CellPhysics + { + BSP = bsp, + WorldTransform = translation, + InverseWorldTransform = inv, + Resolved = resolved, + }; + + // The player's local foot sphere centre at (0,0,0.4) overlaps the floor at Z=0. + var localFoot = new Vector3(0f, 0f, 0.4f); + var transition = new Transition(); + + bool found = transition.TryFindIndoorWalkablePlane( + cell, localFoot, sphereRadius: 0.48f, + 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}"); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs new file mode 100644 index 0000000..e4dd879 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs @@ -0,0 +1,111 @@ +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; +using Plane = System.Numerics.Plane; + +namespace AcDream.Core.Tests.Physics; + +public class TransitionTypesTests +{ + [Fact] + public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance() + { + // Build a CellPhysics with two horizontal walkable polygons at + // local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1]. + // Foot sphere at local Z=0.4 → sphere overlaps the Z=0 polygon + // (|0.4| < radius 0.48); Z=3 is out of range. Expect the lower poly + // to be returned. Sentinel WalkableAllowance value must be preserved + // across the call. + + var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f); + + var transition = new Transition(); + const float sentinelAllowance = 0.42f; + transition.SpherePath.WalkableAllowance = sentinelAllowance; + transition.SpherePath.WalkInterp = 1.0f; + + bool found = transition.TryFindIndoorWalkablePlane( + cellPhysics, + localFootCenter: new Vector3(0.5f, 0.5f, 0.4f), + sphereRadius: 0.48f, + out var worldPlane, + out var worldVertices, + out var hitPolyId); + + Assert.True(found); + // Lower polygon's local plane Normal.Z = 1.0; identity world transform + // means world Normal.Z is also 1.0. + Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3); + // World vertices match the lower polygon (Z=0 in world space, identity transform). + Assert.Equal(4, worldVertices.Length); + Assert.Equal(0f, worldVertices[0].Z, precision: 3); + // hitPolyId is the dictionary key — lower polygon was inserted as key 0. + Assert.Equal(0u, hitPolyId); + // WalkableAllowance must be restored to the sentinel. + Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance); + } + + /// + /// Build a minimal CellPhysics with two horizontal walkable polygons at + /// local Z=lowerZ and Z=upperZ. Identity world transform so world == local. + /// + private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ) + { + Vector3[] lowerVerts = + { + new Vector3(0f, 0f, lowerZ), + new Vector3(1f, 0f, lowerZ), + new Vector3(1f, 1f, lowerZ), + new Vector3(0f, 1f, lowerZ), + }; + Vector3[] upperVerts = + { + new Vector3(0f, 0f, upperZ), + new Vector3(1f, 0f, upperZ), + new Vector3(1f, 1f, upperZ), + new Vector3(0f, 1f, upperZ), + }; + + var resolved = new Dictionary + { + [0] = new ResolvedPolygon + { + Vertices = lowerVerts, + Plane = new Plane(Vector3.UnitZ, -lowerZ), + NumPoints = 4, + SidesType = CullMode.None, + }, + [1] = new ResolvedPolygon + { + Vertices = upperVerts, + Plane = new Plane(Vector3.UnitZ, -upperZ), + NumPoints = 4, + SidesType = CullMode.None, + }, + }; + + var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f); + float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f; + float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight); + + var root = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = center, Radius = radius }, + }; + root.Polygons.Add(0); + root.Polygons.Add(1); + + var bsp = new PhysicsBSPTree { Root = root }; + + return new CellPhysics + { + BSP = bsp, + Resolved = resolved, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + } +}