fix(physics): #32 L.2c precipice edge-slide context

Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping.

Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-04-30 08:04:37 +02:00
parent 1ec40f2a4f
commit 261322b48e
10 changed files with 559 additions and 60 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -377,30 +377,33 @@ public static class BSPQuery
///
/// <para>ACE: Polygon.cs find_crossed_edge.</para>
/// </summary>
private static bool FindCrossedEdge(
ResolvedPolygon poly,
CollisionSphere sphere,
Vector3 up,
ref Vector3 normal)
internal static bool FindCrossedEdge(
Plane polyPlane,
ReadOnlySpan<Vector3> 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<Vector3> 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<Vector3> 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;
}

View file

@ -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);
/// <summary>
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
/// <see cref="CellSurface"/> to resolve entity movement with step-height
@ -162,6 +169,51 @@ public sealed class PhysicsEngine
return null;
}
/// <summary>
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
/// plus world-space triangle vertices for retail precipice-slide.
/// </summary>
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;
}
/// <summary>
/// Resolve the outdoor cell id that owns a world-space position.
/// Indoor ids are preserved because EnvCell ownership still comes from

View file

@ -1,7 +1,13 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon(
float Z,
Vector3 Normal,
Vector3[] Vertices);
/// <summary>
/// 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the

View file

@ -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;
}
/// <summary>
/// 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);
}
/// <summary>
/// Slide along the edge of the walkable polygon the mover just left.
/// Retail anchor: <c>SPHEREPATH::precipice_slide</c>
/// (<c>acclient_2013_pseudo_c.txt:274316</c>).
/// </summary>
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);
}
/// <summary>
/// Initialize the path for a simple point-to-point movement.
/// </summary>
@ -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);
}
/// <summary>
@ -829,12 +978,19 @@ public sealed class Transition
/// </summary>
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

View file

@ -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()
{

View file

@ -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()
{