diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 7112f33..6033efe 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -189,19 +189,22 @@ step-down boundaries, retail often slides along the boundary. acdream still
hard-blocks or accepts too much in several of these cases.
**Root cause / status:** Tracked under Phase L.2c. Wall-adjacent
-`step_up_slide` now feels acceptable in live testing. L.2c plumbing now passes
-the retail-default `EdgeSlide` flag into local and remote movement and logs
-failed step-down edge cases behind `ACDREAM_DUMP_EDGE_SLIDE=1`. Remaining gap:
-preserve walkable polygon context for `precipice_slide` and finish
-`cliff_slide` / `NegPolyHit` dispatch. Named retail anchors include
-`CTransition::edge_slide`, `CTransition::cliff_slide`,
-`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
+`step_up_slide` now feels acceptable in live testing. Local/remote movement
+passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
+preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
+before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
+whether a failed step-down had polygon context. Remaining gaps: real-DAT
+building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
+dispatch. Named retail anchors include `CTransition::edge_slide`,
+`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
+`SPHEREPATH::step_up_slide`.
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
`src/AcDream.Core/Physics/BSPQuery.cs`,
`tests/AcDream.Core.Tests/`.
-**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
+**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`,
+`docs/research/2026-04-30-precipice-slide-pseudocode.md`.
**Acceptance:** Synthetic and real-DAT tests cover wall-slide, roof-edge slide,
cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md
index f31acca..90de943 100644
--- a/docs/plans/2026-04-29-movement-collision-conformance.md
+++ b/docs/plans/2026-04-29-movement-collision-conformance.md
@@ -118,6 +118,13 @@ precipices.
edge, walkable, and collision rules; jumping clears `OnWalkable` and only
succeeds when the airborne path actually clears geometry.
+Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
+acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
+BSP step-down/find-walkable now preserve walkable polygon vertices; failed
+step-down edge cases perform the retail back-probe before
+`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
+fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
+
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
Goal: object collisions use retail shape semantics, not one simplified
diff --git a/docs/research/2026-04-30-precipice-slide-pseudocode.md b/docs/research/2026-04-30-precipice-slide-pseudocode.md
new file mode 100644
index 0000000..8ddc4c8
--- /dev/null
+++ b/docs/research/2026-04-30-precipice-slide-pseudocode.md
@@ -0,0 +1,110 @@
+# Precipice Slide Pseudocode
+
+Date: 2026-04-30
+
+Phase: L.2c - Movement & Collision Conformance
+
+## Retail Anchors
+
+- Named retail: `CTransition::edge_slide`, `acclient_2013_pseudo_c.txt:273001`
+- Named retail: `CTransition::cliff_slide`, `acclient_2013_pseudo_c.txt:272397`
+- Named retail: `SPHEREPATH::precipice_slide`, `acclient_2013_pseudo_c.txt:274316`
+- ACE cross-check: `Transition.EdgeSlide`, `Transition.CliffSlide`,
+ `SpherePath.PrecipiceSlide`
+- ACE cross-check: `Polygon.find_crossed_edge`
+
+## Edge-Slide Flow
+
+When a grounded mover has contact state but the next candidate position has no
+walkable surface within step-down reach, retail does not immediately accept the
+fall or hard-stop. It enters `CTransition::edge_slide`.
+
+```text
+edge_slide(transitionState, stepDownHeight, walkableZ):
+ if object is not OnWalkable or EdgeSlide is disabled:
+ clear walkable
+ restore candidate check position
+ clear current contact plane
+ mark cell array valid
+ transitionState = OK
+ return handled
+
+ if current collision has a contact plane below walkableZ:
+ transitionState = cliff_slide(contact plane)
+ clear walkable and restore candidate check position
+ clear current contact plane
+ return not-final
+
+ if sphere_path.walkable exists:
+ transitionState = precipice_slide()
+ clear current contact plane and restore candidate check position
+ return transitionState == Collided
+
+ if current collision has any contact plane:
+ clear walkable
+ restore candidate check position
+ clear current contact plane
+ transitionState = OK
+ return handled
+
+ move CheckPos back from failed candidate to the current sphere center
+ step_down(stepDownHeight, walkableZ) to rediscover the walkable polygon
+ clear current contact plane
+ restore the failed candidate check position
+
+ if a walkable polygon was discovered:
+ set walkable_check_pos from the candidate sphere in walkable space
+ transitionState = precipice_slide()
+ return transitionState == Collided
+
+ clear walkable
+ mark cell array valid
+ transitionState = Collided
+ return handled
+```
+
+## Precipice Slide
+
+`SPHEREPATH::precipice_slide` is the edge-normal half of edge-slide. The crucial
+input is the walkable polygon that the mover just left; without that polygon,
+there is no crossed edge to slide along.
+
+```text
+precipice_slide():
+ normal = zero
+ found = walkable.find_crossed_edge(walkable_check_pos, walkable_up, normal)
+
+ if not found:
+ clear walkable
+ return Collided
+
+ clear walkable
+ step_up = false
+
+ normal = walkable_pos.frame.LocalToGlobalVec(normal)
+
+ blockOffset = LandDefs.GetBlockOffset(curr cell, check cell)
+ movementOffset = global_sphere.center - global_curr_center.center + blockOffset
+
+ if dot(normal, movementOffset) > 0:
+ normal = -normal
+
+ return global_sphere.slide_sphere(transition, normal, global_curr_center.center)
+```
+
+## Porting Notes
+
+acdream already had the `Polygon.find_crossed_edge` math inside `BSPQuery`, but
+the live diagnostic showed `walkableValid=False` at the failed step-down edge
+branch. The port must therefore preserve or rediscover the walkable polygon,
+not just pass the `EdgeSlide` flag.
+
+For the first L.2c slice:
+
+- terrain supplies the exact current triangle vertices alongside its plane;
+- BSP step-down/find-walkable records world-space polygon vertices when the
+ caller supplies the object's world origin;
+- the failed step-down edge branch performs the retail back-probe to current
+ position before calling precipice slide;
+- `CELLARRAY`, full `cell_bsp` ownership, and cross-cell building portals remain
+ L.2e work.
diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md
index 1fa54b8..5db6326 100644
--- a/memory/project_movement_collision_conformance.md
+++ b/memory/project_movement_collision_conformance.md
@@ -62,5 +62,13 @@ InputDispatcher / PlayerMovementController
- 2026-04-30: L.2c edge-slide plumbing. User live-tested wall-adjacent slide as
acceptable. Local player and remote dead-reckoning now pass retail-default
`ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed
- step-down edge cases so the next slice can distinguish missing walkable
- polygon context from cliff-slide/NegPolyHit gaps.
+ step-down edge cases and now reports whether walkable polygon context is
+ present before cliff/precipice handling.
+- 2026-04-30: L.2c precipice-slide context. Named retail
+ `SPHEREPATH::precipice_slide` and ACE `Polygon.find_crossed_edge` are now
+ captured in `docs/research/2026-04-30-precipice-slide-pseudocode.md`.
+ Terrain supplies exact walkable triangle vertices, BSP step-down/find-walkable
+ stores world-space walkable vertices for static object tops, and failed
+ step-down edge cases run the retail back-probe before precipice slide.
+ `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
+ `cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index a0947d0..b86ccc4 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -377,30 +377,33 @@ public static class BSPQuery
///
/// ACE: Polygon.cs find_crossed_edge.
///
- private static bool FindCrossedEdge(
- ResolvedPolygon poly,
- CollisionSphere sphere,
- Vector3 up,
- ref Vector3 normal)
+ internal static bool FindCrossedEdge(
+ Plane polyPlane,
+ ReadOnlySpan verts,
+ Vector3 sphereCenter,
+ Vector3 up,
+ out Vector3 normal)
{
- float angleUp = Vector3.Dot(poly.Plane.Normal, up);
+ normal = Vector3.Zero;
+
+ float angleUp = Vector3.Dot(polyPlane.Normal, up);
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
- float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
- var center = sphere.Center - up * angle;
+ float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
+ var center = sphereCenter - up * angle;
- int n = poly.Vertices.Length;
+ int n = verts.Length;
int prevIdx = n - 1;
for (int i = 0; i < n; i++)
{
- var v = poly.Vertices[i];
- var lv = poly.Vertices[prevIdx];
+ var v = verts[i];
+ var lv = verts[prevIdx];
prevIdx = i;
var edge = v - lv;
var disp = center - lv;
- var cross = Vector3.Cross(poly.Plane.Normal, edge);
+ var cross = Vector3.Cross(polyPlane.Normal, edge);
if (Vector3.Dot(disp, cross) < 0f)
{
@@ -412,6 +415,47 @@ public static class BSPQuery
return false;
}
+ private static bool FindCrossedEdge(
+ ResolvedPolygon poly,
+ CollisionSphere sphere,
+ Vector3 up,
+ ref Vector3 normal)
+ {
+ if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal))
+ return false;
+
+ normal = crossedNormal;
+ return true;
+ }
+
+ private static Vector3 TransformNormal(Vector3 normal, Quaternion localToWorld)
+ {
+ var worldNormal = Vector3.Transform(normal, localToWorld);
+ return worldNormal.LengthSquared() > PhysicsGlobals.EpsilonSq
+ ? Vector3.Normalize(worldNormal)
+ : Vector3.UnitZ;
+ }
+
+ private static Vector3[] TransformVertices(
+ ReadOnlySpan vertices,
+ Quaternion localToWorld,
+ float scale,
+ Vector3 worldOrigin)
+ {
+ var result = new Vector3[vertices.Length];
+ for (int i = 0; i < vertices.Length; i++)
+ result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin;
+ return result;
+ }
+
+ private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan worldVertices)
+ {
+ float d = worldVertices.Length > 0
+ ? -Vector3.Dot(worldNormal, worldVertices[0])
+ : 0f;
+ return new Plane(worldNormal, d);
+ }
+
// -------------------------------------------------------------------------
// adjust_to_placement_poly
// ACE: Polygon.cs adjust_to_placement_poly
@@ -1037,7 +1081,8 @@ public static class BSPQuery
CollisionSphere checkPos,
Vector3 up,
float scale,
- Quaternion localToWorld = default)
+ Quaternion localToWorld = default,
+ Vector3 worldOrigin = default)
{
if (localToWorld == default) localToWorld = Quaternion.Identity;
@@ -1061,14 +1106,12 @@ public static class BSPQuery
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
path.AddOffsetToCheckPos(offset);
- var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
- collisions.SetContactPlane(
- new Plane(worldNormal, polyHit.Plane.D * scale),
- path.CheckCellId, false);
+ var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
+ var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
+ var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
+ collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
- path.WalkableValid = true;
- path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
- path.WalkableAllowance = PhysicsGlobals.FloorZ;
+ path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
@@ -1359,7 +1402,8 @@ public static class BSPQuery
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld = default,
- PhysicsEngine? engine = null)
+ PhysicsEngine? engine = null,
+ Vector3 worldOrigin = default)
{
if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity
@@ -1410,7 +1454,7 @@ public static class BSPQuery
// ----------------------------------------------------------------
if (path.StepDown)
{
- return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld);
+ return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin);
}
// ----------------------------------------------------------------
@@ -1433,14 +1477,12 @@ public static class BSPQuery
var worldOffset = L2W(localOffset) * scale;
path.AddOffsetToCheckPos(worldOffset);
- var worldNormal = L2W(hitPoly.Plane.Normal);
- collisions.SetContactPlane(
- new Plane(worldNormal, hitPoly.Plane.D * scale),
- path.CheckCellId, false);
+ var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
+ var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
+ var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
+ collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
- path.WalkableValid = true;
- path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
- path.WalkableAllowance = PhysicsGlobals.FloorZ;
+ path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
return TransitionState.Adjusted;
}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index be2494b..22f48ef 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -4,6 +4,13 @@ using System.Numerics;
namespace AcDream.Core.Physics;
+internal readonly record struct TerrainWalkableSample(
+ System.Numerics.Plane Plane,
+ Vector3[] Vertices,
+ float WaterDepth,
+ bool IsWater,
+ uint CellId);
+
///
/// Top-level physics resolver that combines and
/// to resolve entity movement with step-height
@@ -162,6 +169,51 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Sample the outdoor terrain walkable triangle at the given world-space
+ /// XY position. This carries the same plane as
+ /// plus world-space triangle vertices for retail precipice-slide.
+ ///
+ internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
+ {
+ foreach (var kvp in _landblocks)
+ {
+ var lb = kvp.Value;
+ float localX = worldX - lb.WorldOffsetX;
+ float localY = worldY - lb.WorldOffsetY;
+ if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
+ {
+ var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
+ var vertices = new Vector3[sample.Vertices.Length];
+ for (int i = 0; i < sample.Vertices.Length; i++)
+ {
+ var v = sample.Vertices[i];
+ vertices[i] = new Vector3(
+ v.X + lb.WorldOffsetX,
+ v.Y + lb.WorldOffsetY,
+ v.Z);
+ }
+
+ var normal = sample.Normal;
+ float d = -Vector3.Dot(normal, vertices[0]);
+ var plane = new System.Numerics.Plane(normal, d);
+
+ float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
+ bool isWater = waterDepth >= 0.45f;
+ uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
+ uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
+
+ return new TerrainWalkableSample(
+ plane,
+ vertices,
+ waterDepth,
+ isWater,
+ fullCellId);
+ }
+ }
+ return null;
+ }
+
///
/// Resolve the outdoor cell id that owns a world-space position.
/// Indoor ids are preserved because EnvCell ownership still comes from
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index 6a37506..caa5493 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -1,7 +1,13 @@
using System;
+using System.Numerics;
namespace AcDream.Core.Physics;
+public readonly record struct TerrainSurfacePolygon(
+ float Z,
+ Vector3 Normal,
+ Vector3[] Vertices);
+
///
/// Outdoor terrain height resolver for a single landblock. Performs
/// per-triangle barycentric Z interpolation matching the visual terrain
@@ -250,6 +256,72 @@ public sealed class TerrainSurface
return (z, normal);
}
+ ///
+ /// Sample the terrain triangle at (localX, localY), including the three
+ /// local-space vertices that bound the sampled point. Edge-slide needs
+ /// these vertices so the retail crossed-edge test can identify which edge
+ /// the sphere left when a step-down probe fails.
+ ///
+ public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
+ {
+ float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
+ float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
+ int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
+ int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
+
+ float tx = fx - cx;
+ float ty = fy - cy;
+
+ float hBL = _z[cx, cy ];
+ float hBR = _z[cx + 1, cy ];
+ float hTR = _z[cx + 1, cy + 1];
+ float hTL = _z[cx, cy + 1];
+
+ bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
+
+ Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
+ Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
+ Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
+ Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
+
+ float z;
+ Vector3[] vertices;
+
+ if (splitSWtoNE)
+ {
+ if (tx > ty)
+ {
+ z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
+ vertices = new[] { bl, br, tr };
+ }
+ else
+ {
+ z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
+ vertices = new[] { bl, tr, tl };
+ }
+ }
+ else
+ {
+ if (tx + ty <= 1f)
+ {
+ z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
+ vertices = new[] { bl, br, tl };
+ }
+ else
+ {
+ z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
+ vertices = new[] { br, tr, tl };
+ }
+ }
+
+ var normal = Vector3.Normalize(
+ Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
+ if (normal.Z < 0f)
+ normal = -normal;
+
+ return new TerrainSurfacePolygon(z, normal, vertices);
+ }
+
///
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 8bdaef6..d48a0a0 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -188,7 +188,10 @@ public sealed class SpherePath
// Walkable tracking
public bool WalkableValid;
public Plane WalkablePlane;
+ public Vector3[]? WalkableVertices;
+ public Vector3 WalkableUp = Vector3.UnitZ;
public float WalkableAllowance = PhysicsGlobals.FloorZ;
+ public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
// Backup for restore
public Vector3 BackupCheckPos;
@@ -246,6 +249,21 @@ public sealed class SpherePath
WalkInterp = 1.0f;
}
+ public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up)
+ {
+ WalkableValid = true;
+ WalkablePlane = plane;
+ WalkableVertices = (Vector3[])vertices.Clone();
+ WalkableUp = up;
+ WalkableAllowance = PhysicsGlobals.FloorZ;
+ }
+
+ public void ClearWalkable()
+ {
+ WalkableValid = false;
+ WalkableVertices = null;
+ }
+
///
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation
@@ -273,6 +291,40 @@ public sealed class SpherePath
return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
}
+ ///
+ /// Slide along the edge of the walkable polygon the mover just left.
+ /// Retail anchor: SPHEREPATH::precipice_slide
+ /// (acclient_2013_pseudo_c.txt:274316).
+ ///
+ public TransitionState PrecipiceSlide(Transition transition)
+ {
+ if (!HasWalkablePolygon || WalkableVertices is null)
+ {
+ ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ if (!BSPQuery.FindCrossedEdge(
+ WalkablePlane,
+ WalkableVertices,
+ GlobalSphere[0].Origin,
+ WalkableUp,
+ out var collisionNormal))
+ {
+ ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ ClearWalkable();
+ StepUp = false;
+
+ var offset = GlobalSphere[0].Origin - GlobalCurrCenter[0].Origin;
+ if (Vector3.Dot(collisionNormal, offset) > 0f)
+ collisionNormal = -collisionNormal;
+
+ return transition.SlideSphereInternal(collisionNormal, GlobalCurrCenter[0].Origin);
+ }
+
///
/// Initialize the path for a simple point-to-point movement.
///
@@ -583,14 +635,14 @@ public sealed class Transition
else if (!reset)
{
// Placement accepted — return current state.
- sp.WalkableValid = false;
+ sp.ClearWalkable();
return placeState;
}
}
else
reset = true;
- sp.WalkableValid = false;
+ sp.ClearWalkable();
if (reset)
{
@@ -653,7 +705,7 @@ public sealed class Transition
{
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
- sp.WalkableValid = false;
+ sp.ClearWalkable();
return TransitionState.OK;
}
}
@@ -663,7 +715,7 @@ public sealed class Transition
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
- sp.WalkableValid = false;
+ sp.ClearWalkable();
return TransitionState.OK;
}
}
@@ -681,8 +733,7 @@ public sealed class Transition
// we are missing precipice context, a steep contact plane, or
// merely the EdgeSlide flag.
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
- sp.RestoreCheckPos();
- return TransitionState.Collided;
+ return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
}
return TransitionState.OK;
@@ -693,6 +744,105 @@ public sealed class Transition
return TransitionState.Slid;
}
+ private TransitionState EdgeSlideAfterStepDownFailed(
+ PhysicsEngine engine,
+ float stepDownHeight,
+ float zVal)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+ var oi = ObjectInfo;
+
+ // Retail lets non-EdgeSlide movers continue over the boundary. Player
+ // movement carries EdgeSlide, so the local avatar takes the slide path.
+ if (!oi.OnWalkable || !oi.EdgeSlide)
+ {
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return TransitionState.OK;
+ }
+
+ if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
+ {
+ var cliffPlane = ci.ContactPlane;
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return CliffSlide(cliffPlane);
+ }
+
+ if (sp.HasWalkablePolygon)
+ {
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return sp.PrecipiceSlide(this);
+ }
+
+ if (ci.ContactPlaneValid)
+ {
+ sp.ClearWalkable();
+ sp.RestoreCheckPos();
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ return TransitionState.OK;
+ }
+
+ // Retail back-probes from the current sphere center to rediscover the
+ // walkable polygon we just left, then restores the failed candidate and
+ // runs precipice_slide against that polygon.
+ Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin;
+ sp.AddOffsetToCheckPos(backToCurrent);
+
+ _ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false);
+
+ ci.ContactPlaneValid = false;
+ ci.ContactPlaneIsWater = false;
+ sp.RestoreCheckPos();
+
+ if (sp.HasWalkablePolygon)
+ return sp.PrecipiceSlide(this);
+
+ sp.ClearWalkable();
+ return TransitionState.Collided;
+ }
+
+ private TransitionState CliffSlide(Plane contactPlane)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+
+ if (!ci.LastKnownContactPlaneValid)
+ return TransitionState.OK;
+
+ Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
+ contactNormal.Z = 0f;
+
+ Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
+ if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ return TransitionState.OK;
+
+ collideNormal = Vector3.Normalize(collideNormal);
+
+ Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
+ float angle = Vector3.Dot(collideNormal, offset);
+
+ if (angle <= 0f)
+ {
+ sp.AddOffsetToCheckPos(collideNormal * angle);
+ ci.SetCollisionNormal(collideNormal);
+ }
+ else
+ {
+ sp.AddOffsetToCheckPos(collideNormal * -angle);
+ ci.SetCollisionNormal(-collideNormal);
+ }
+
+ return TransitionState.Adjusted;
+ }
+
private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal)
{
if (!DumpEdgeSlideEnabled) return;
@@ -703,7 +853,7 @@ public sealed class Transition
Console.WriteLine(
System.FormattableString.Invariant(
- $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
+ $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
}
private static string Fmt(Vector3 value) =>
@@ -800,10 +950,10 @@ public sealed class Transition
//
// ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls
// find_terrain_poly and uses walkable.Plane — the actual triangle's
- // plane, not a reconstructed flat one. SampleTerrainPlane returns
- // the same thing analytically from the triangle's corner heights.
- var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y);
- if (planeOpt is null)
+ // plane, not a reconstructed flat one. SampleTerrainWalkable returns
+ // that plane plus the triangle vertices needed by precipice slide.
+ var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
+ if (terrainWalkable is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
@@ -813,12 +963,11 @@ public sealed class Transition
// contact plane before the push-up fires. In retail, this is what
// makes characters appear submerged in water — there is NO separate
// water surface mesh; the character just sits lower than terrain.
- float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
- bool isWater = waterDepth >= 0.45f;
-
- return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
- isWater, waterDepth,
- cellId: sp.CheckCellId);
+ return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
+ terrainWalkable.Value.IsWater,
+ terrainWalkable.Value.WaterDepth,
+ cellId: terrainWalkable.Value.CellId,
+ walkableVertices: terrainWalkable.Value.Vertices);
}
///
@@ -829,12 +978,19 @@ public sealed class Transition
///
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane,
- bool isWater, float waterDepth, uint cellId)
+ bool isWater, float waterDepth, uint cellId,
+ Vector3[]? walkableVertices = null)
{
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
+ void CacheWalkableContext()
+ {
+ if (walkableVertices is not null && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
+ sp.SetWalkable(contactPlane, walkableVertices, Vector3.UnitZ);
+ }
+
// Low point of the sphere.
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
@@ -857,7 +1013,10 @@ public sealed class Transition
// Resting on surface: record contact plane.
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
+ {
ci.SetContactPlane(contactPlane, cellId, isWater);
+ CacheWalkableContext();
+ }
if (!oi.Contact && !sp.StepDown)
{
@@ -879,6 +1038,7 @@ public sealed class Transition
if (sp.StepDown || !oi.OnWalkable || walkable)
{
ci.SetContactPlane(contactPlane, cellId, isWater);
+ CacheWalkableContext();
if (sp.StepDown)
{
@@ -1020,7 +1180,8 @@ public sealed class Transition
localSpaceZ,
obj.Scale, // scale for local→world offsets
obj.Rotation, // local→world rotation
- engine); // engine needed for Path 5 step-up
+ engine,
+ worldOrigin: obj.Position);
}
else
{
@@ -1499,8 +1660,8 @@ public sealed class Transition
bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
- sp.StepUp = false;
- sp.WalkableValid = false;
+ sp.StepUp = false;
+ sp.ClearWalkable();
// L.2.3f: log the result + landing plane if step-up succeeded.
// This is the actual surface the player ended up on, which may
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index d6f08cf..79d6ac8 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -231,6 +231,36 @@ public class PhysicsEngineTests
Assert.Equal(0x0025u, result.CellId);
}
+ [Fact]
+ public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary()
+ {
+ var engine = MakeFlatEngine(terrainZ: 50f);
+ var body = new PhysicsBody
+ {
+ Position = new Vector3(191.25f, 96f, 50f),
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ ContactPlaneValid = true,
+ ContactPlane = new Plane(Vector3.UnitZ, -50f),
+ ContactPlaneCellId = 0x003Du,
+ };
+
+ var result = engine.ResolveWithTransition(
+ currentPos: new Vector3(191.25f, 96f, 50f),
+ targetPos: new Vector3(193f, 96f, 50f),
+ cellId: 0x003Du,
+ sphereRadius: 0.5f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 0.4f,
+ stepDownHeight: 0.4f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.EdgeSlide);
+
+ Assert.True(result.IsOnGround);
+ Assert.InRange(result.Position.X, 190.75f, 192.0001f);
+ Assert.Equal(50f, result.Position.Z, precision: 2);
+ }
+
[Fact]
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
{
diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
index fb70cd4..7ceda8f 100644
--- a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs
@@ -67,6 +67,20 @@ public class TerrainSurfaceTests
Assert.Equal(42f, surface.SampleZ(300f, 300f));
}
+ [Fact]
+ public void SampleSurfacePolygon_ReturnsContainingTriangleVertices()
+ {
+ var heights = FlatHeightmap(50);
+ var surface = new TerrainSurface(heights, LinearHeightTable(), landblockX: 0, landblockY: 0);
+
+ var sample = surface.SampleSurfacePolygon(2f, 2f);
+
+ Assert.Equal(3, sample.Vertices.Length);
+ Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
+ Assert.Equal(1f, sample.Normal.Z, precision: 3);
+ Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
+ }
+
[Fact]
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
{