diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 9c2e816..01ffc0d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -6149,15 +6149,42 @@ public sealed class GameWindow : IDisposable
// velocity, keeping legs and body pace synchronized.
// - Blip-to-tail (tail − body) when fail_count > 3.
float maxSpeed = rm.Motion.GetMaxSpeed();
+ // Slope-staircase fix (2026-05-05): sample terrain normal
+ // at the body's current XY so PositionManager can project
+ // the seqVel-only fallback onto the local slope. Without
+ // this, the queue-empty interval between UPs left Z flat
+ // (anim cycles bake Z=0 body-local) — visible ~5 Hz
+ // staircase when a remote runs up/down hills. The
+ // projection is a no-op on flat ground.
+ System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
+ rm.Body.Position.X, rm.Body.Position.Y);
+
+ System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVel,
ori: rm.Body.Orientation,
interp: rm.Interp,
- maxSpeed: maxSpeed);
+ maxSpeed: maxSpeed,
+ terrainNormal: terrainNormal);
rm.Body.Position += offset;
+ // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
+ // Prints per-tick body Z trajectory + queue state + projected
+ // offset.Z so we can grep before/after the fix and confirm Z
+ // changes continuously between UPs on slopes (no flat
+ // intervals followed by snaps).
+ if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
+ {
+ bool queueActive = rm.Interp.IsActive;
+ float nz = terrainNormal?.Z ?? 1.0f;
+ System.Console.WriteLine(
+ $"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
+ + $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
+ + $"queue={queueActive} cpN.Z={nz:F3}");
+ }
+
// Step 2.5: angular velocity → body orientation. Prefer
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
// the wire's TurnCommand + signed TurnSpeed) over the
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 671eb2b..fe308ae 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -169,6 +169,21 @@ public sealed class PhysicsEngine
return null;
}
+ ///
+ /// Public surface for callers that only need the local terrain plane
+ /// normal at a world-space XY (e.g., the grounded-remote tick path
+ /// projecting anim root motion onto the slope to avoid the staircase
+ /// between server position updates). Returns null when no registered
+ /// landblock covers the point. Mirrors the plane component of
+ /// without exposing the internal
+ /// TerrainWalkableSample shape.
+ ///
+ public Vector3? SampleTerrainNormal(float worldX, float worldY)
+ {
+ var sample = SampleTerrainWalkable(worldX, worldY);
+ return sample?.Plane.Normal;
+ }
+
///
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as
diff --git a/src/AcDream.Core/Physics/PositionManager.cs b/src/AcDream.Core/Physics/PositionManager.cs
index be3dbc0..e3ff2ae 100644
--- a/src/AcDream.Core/Physics/PositionManager.cs
+++ b/src/AcDream.Core/Physics/PositionManager.cs
@@ -34,13 +34,28 @@ public sealed class PositionManager
/// Body orientation; used to rotate seqVel from body-local to world.
/// The remote's InterpolationManager (for AdjustOffset call).
/// From MotionInterpreter.GetMaxSpeed() — passed to AdjustOffset for the catch-up clamp.
+ ///
+ /// Optional local terrain plane normal at the body's current XY. When
+ /// supplied AND the queue-empty / head-reached fallback path runs, the
+ /// world-space anim root motion is projected onto the plane so XY motion
+ /// produces a corresponding Z change on slopes. Without this, the
+ /// fallback advances XY at the locomotion cycle's pace but leaves Z at
+ /// the last UP's reported Z — visible as a ~5 Hz staircase on slopes
+ /// (the rate of server UpdatePositions). Mirrors retail's
+ /// CTransition::adjust_offset contact-plane projection
+ /// (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
+ /// motion, applied here at the queue-empty boundary instead of inside
+ /// the sweep. Pass null on flat ground / when no terrain sample
+ /// is available — projection is a no-op when normal == +Z.
+ ///
public Vector3 ComputeOffset(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel,
Quaternion ori,
InterpolationManager interp,
- float maxSpeed)
+ float maxSpeed,
+ Vector3? terrainNormal = null)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
@@ -71,6 +86,23 @@ public sealed class PositionManager
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
- return Vector3.Transform(rootMotionLocal, ori);
+ Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
+
+ // Slope projection (queue-empty fallback only). Locomotion cycles
+ // bake Z=0 in body-local, so without projection the body's Z stays
+ // at the last UP's reported value while XY advances at the running
+ // pace — visible ~5 Hz staircase between UPs on hills. Projecting
+ // the world-space anim motion onto the local terrain plane gives
+ // it a Z component proportional to slope × forward speed, so the
+ // body follows the terrain mesh smoothly. No-op on flat ground
+ // (normal ≈ +Z, dot ≈ 0) so it can't regress the M2 flat-ground
+ // verification.
+ if (terrainNormal.HasValue && terrainNormal.Value.Z > 0.01f)
+ {
+ Vector3 N = terrainNormal.Value;
+ float into = Vector3.Dot(rootMotionWorld, N);
+ rootMotionWorld -= N * into;
+ }
+ return rootMotionWorld;
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
index 7839bc6..0242c2a 100644
--- a/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
@@ -176,4 +176,64 @@ public sealed class PositionManagerTests
Assert.Equal(0f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4);
}
+
+ // =========================================================================
+ // Test 7: slope projection — anim root motion gains Z proportional to slope
+ //
+ // Lock-the-fix for the "remote running on a slope shows ~5 Hz Z staircase"
+ // bug: the queue-empty fallback was returning a flat (Z=0) world motion
+ // because animation cycles bake Z=0 in body-local. Projecting onto the
+ // local terrain plane gives the motion a Z component matching slope angle
+ // × forward speed.
+ // =========================================================================
+
+ [Fact]
+ public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
+ {
+ var pm = Make();
+ var interp = EmptyInterp(); // queue empty → fallback path runs
+
+ // Slope tilted 30° eastward (+X is downhill). Plane normal points
+ // up-and-east-of-vertical: (sin 30°, 0, cos 30°) = (0.5, 0, 0.866).
+ Vector3 N = Vector3.Normalize(new Vector3(0.5f, 0f, MathF.Sqrt(3f) / 2f));
+
+ // Body running due east at 4 m/s, dt = 1s → rootMotionWorld initially
+ // (4, 0, 0). After projection onto the plane:
+ // into = dot((4,0,0), (0.5,0,0.866)) = 2.0
+ // result = (4,0,0) - (0.5,0,0.866) * 2.0 = (3.0, 0, -1.732)
+ // i.e. body moves east AND descends ~1.73m for the second.
+ Vector3 offset = pm.ComputeOffset(
+ dt: 1.0,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(4f, 0f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 0f,
+ terrainNormal: N);
+
+ Assert.Equal( 3.000f, offset.X, precision: 3);
+ Assert.Equal( 0.000f, offset.Y, precision: 3);
+ Assert.Equal(-1.732f, offset.Z, precision: 3);
+ }
+
+ [Fact]
+ public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
+ {
+ var pm = Make();
+ var interp = EmptyInterp();
+
+ // Flat ground: normal = +Z. Projection should be a no-op.
+ Vector3 offset = pm.ComputeOffset(
+ dt: 0.1,
+ currentBodyPosition: Vector3.Zero,
+ seqVel: new Vector3(0f, 4f, 0f),
+ ori: Quaternion.Identity,
+ interp: interp,
+ maxSpeed: 0f,
+ terrainNormal: Vector3.UnitZ);
+
+ Assert.Equal(0f, offset.X, precision: 4);
+ Assert.Equal(0.4f, offset.Y, precision: 4);
+ Assert.Equal(0f, offset.Z, precision: 4);
+ }
}