diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index aa5fd2b..1ae9a03 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -110,6 +110,35 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Sample the outdoor terrain plane (Z + sloped normal) at the given
+ /// world-space XY position. The returned
+ /// has the true terrain-triangle normal (NOT a flat (0,0,1)), and
+ /// its D is set so the plane passes through the sampled point. Used
+ /// by to build a CORRECT contact plane — a flat
+ /// plane breaks slope tracking because AdjustOffset's projection
+ /// onto a flat plane cannot impart the Z component that horizontal
+ /// velocity needs to follow the slope.
+ ///
+ public System.Numerics.Plane? SampleTerrainPlane(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 (z, normal) = lb.Terrain.SampleSurface(localX, localY);
+ // System.Numerics.Plane convention: dot(Normal, P) + D == 0
+ // for points P on the plane. Pick P = (worldX, worldY, z).
+ float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z);
+ return new System.Numerics.Plane(normal, d);
+ }
+ }
+ return null;
+ }
+
///
/// Resolve an entity's movement from by
/// applying (XY only) and computing the correct Z
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index e05cd10..6068c40 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -116,6 +116,99 @@ public sealed class TerrainSurface
}
}
+ ///
+ /// Sample both the terrain Z and the triangle-plane surface normal at
+ /// (localX, localY). The normal is derived from the gradient of the same
+ /// triangle interpolates across, so returned
+ /// (Z, Normal) is exactly the sloped plane the physics should
+ /// contact against.
+ ///
+ ///
+ /// This matters for : when AdjustOffset
+ /// projects the per-step movement offset onto the contact plane, a flat
+ /// plane (Normal = (0,0,1)) cannot impart any Z component to a
+ /// horizontal velocity — the character walks off a slope and the
+ /// per-frame step-down budget (~4 cm) can't catch up with the slope
+ /// descent rate, so the sphere floats above the terrain. A SLOPED plane
+ /// gives AdjustOffset the info it needs to produce slope-aligned motion
+ /// with the correct Z component baked in.
+ ///
+ ///
+ ///
+ /// Retail does this via LandCell.find_terrain_poly → walkable.Plane
+ /// (ACE Landblock.cs:125-137). We derive the equivalent plane
+ /// analytically from the chosen triangle's three corner heights.
+ ///
+ ///
+ public (float Z, System.Numerics.Vector3 Normal) SampleSurface(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 = (int)fx;
+ int cy = (int)fy;
+ cx = Math.Clamp(cx, 0, CellsPerSide - 1);
+ cy = Math.Clamp(cy, 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);
+
+ // The SampleZ formula for each triangle is linear in (tx, ty):
+ // Z = a + b * tx + c * ty
+ // so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize.
+ // Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known
+ // identity for a height-field plane.
+ float z, dzdx, dzdy;
+
+ if (splitSWtoNE)
+ {
+ // Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}.
+ if (tx > ty)
+ {
+ // {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty
+ z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
+ dzdx = (hBR - hBL) / CellSize;
+ dzdy = (hTR - hBR) / CellSize;
+ }
+ else
+ {
+ // {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty
+ z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
+ dzdx = (hTR - hTL) / CellSize;
+ dzdy = (hTL - hBL) / CellSize;
+ }
+ }
+ else
+ {
+ // Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}.
+ if (tx + ty <= 1f)
+ {
+ // {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty
+ z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
+ dzdx = (hBR - hBL) / CellSize;
+ dzdy = (hTL - hBL) / CellSize;
+ }
+ else
+ {
+ // {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty)
+ // Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty
+ z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
+ dzdx = (hTR - hTL) / CellSize;
+ dzdy = (hTR - hBR) / CellSize;
+ }
+ }
+
+ var normal = System.Numerics.Vector3.Normalize(
+ new System.Numerics.Vector3(-dzdx, -dzdy, 1f));
+ return (z, normal);
+ }
+
///
/// Compute the outdoor cell ID for the given landblock-local position.
///
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index d4ac066..6e0e670 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -619,16 +619,25 @@ public sealed class Transition
}
// ── Outdoor terrain collision ────────────────────────────────────
- // Sample terrain Z at the foot sphere's world position.
- float? terrainZ = engine.SampleTerrainZ(footCenter.X, footCenter.Y);
- if (terrainZ is null)
+ // Build the terrain contact plane at the foot sphere's world XY.
+ //
+ // Retail fidelity: plane Normal must be the terrain TRIANGLE normal
+ // (sloped on slopes), NOT a flat (0,0,1). AdjustOffset projects the
+ // per-step movement offset onto this plane — with a flat plane the
+ // projection just zeros the Z component and horizontal velocity stays
+ // horizontal, so the sphere cannot follow a descending slope within
+ // one frame's step-down budget. A sloped plane projects horizontal
+ // velocity INTO slope-aligned motion with the correct Z component.
+ //
+ // 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)
return TransitionState.OK; // no terrain loaded here — allow pass-through
- // Build the terrain contact plane (flat ground: Normal = +Z, D = -terrainZ).
- var contactPlane = new System.Numerics.Plane(
- new Vector3(0f, 0f, 1f), -terrainZ.Value);
-
- return ValidateWalkable(footCenter, sphereRadius, contactPlane, isWater: false,
+ return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value, isWater: false,
cellId: sp.CheckCellId);
}