diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 38dcb58..4d008f1 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -368,7 +368,8 @@ public sealed class PlayerMovementController
sphereHeight: 1.2f, // human player height from Setup
stepUpHeight: StepUpHeight,
stepDownHeight: 0.04f, // retail default
- isOnGround: _body.OnWalkable);
+ isOnGround: _body.OnWalkable,
+ body: _body); // persist ContactPlane across frames for slope tracking
// Apply resolved position.
_body.Position = resolveResult.Position;
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 1ff5461..5e777a9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -209,6 +209,17 @@ public sealed class GameWindow : IDisposable
/// Zeroed on UM with TurnCommand absent.
///
public System.Numerics.Vector3 ObservedOmega;
+ ///
+ /// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
+ /// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
+ /// 0x0040, indoor 0x0100+). Fed into
+ ///
+ /// so the retail sphere-sweep can look up the right terrain/EnvCell
+ /// polygons for each remote's per-frame motion. Zero until the first
+ /// UP lands, which disables the transition step for that frame
+ /// (Euler alone, matching pre-wire behavior).
+ ///
+ public uint CellId;
public RemoteMotion()
{
@@ -2012,6 +2023,12 @@ public sealed class GameWindow : IDisposable
rmState.Body.Orientation = rot;
}
rmState.Body.Position = worldPos;
+ // Adopt the server's cell ID as the transition starting cell.
+ // Retail authoritatively hard-snaps cell membership here too; our
+ // per-tick ResolveWithTransition sweep then advances CheckCellId
+ // as the sphere crosses cells and writes the new cell back into
+ // rmState.CellId so the NEXT frame starts in the correct cell.
+ rmState.CellId = p.LandblockId;
// Retail hard-snaps orientation on UpdatePosition (set_frame,
// FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment).
@@ -3909,12 +3926,67 @@ public sealed class GameWindow : IDisposable
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
}
- // Step 3: integrate physics — retail FUN_00515020
- // update_object → FUN_00513730 UpdatePositionInternal →
- // FUN_005256b0 Sequence::apply_physics. Position and
- // orientation BOTH advance from Velocity/Omega × dt.
- // No slerp, no soft-snap — retail is deterministic.
- rm.Body.update_object(nowSec);
+ // Step 3: integrate physics — retail FUN_005111D0
+ // UpdatePhysicsInternal. Pure Euler:
+ // position += velocity × dt + 0.5 × accel × dt²
+ //
+ // Call UpdatePhysicsInternal DIRECTLY rather than via
+ // PhysicsBody.update_object (FUN_00515020). update_object gates
+ // on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
+ // deltaTime < MinQuantum → early return AND LastUpdateTime is
+ // NOT advanced. Net effect: position never integrates between
+ // UpdatePositions and the only Body.Position changes come
+ // from the UP hard-snap, producing a visible teleport-stride
+ // on slopes (the "staircase" the user reported).
+ //
+ // PlayerMovementController.cs:358 calls UpdatePhysicsInternal
+ // directly for the same reason. Remote motion mirrors that.
+ // Omega is already integrated manually above, so we zero it
+ // here to prevent UpdatePhysicsInternal's own omega pass from
+ // double-integrating.
+ var preIntegratePos = rm.Body.Position;
+ rm.Body.calc_acceleration();
+ rm.Body.UpdatePhysicsInternal(dt);
+ var postIntegratePos = rm.Body.Position;
+
+ // Step 4: collision sweep — retail FUN_00514B90 →
+ // FUN_005148A0 → Transition::FindTransitionalPosition.
+ // Projects the sphere from preIntegratePos to postIntegratePos
+ // through the BSP + terrain, resolving:
+ // - terrain Z snap along the slope (fixes the "staircase" where
+ // horizontal Euler motion up a slope sinks into rising ground
+ // until the next UP pops it up)
+ // - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
+ // - object collisions via ShadowObjectRegistry
+ // - step-up / step-down against walkable ledges
+ // ResolveWithTransition is the same call PlayerMovementController
+ // uses for the local player; remotes now get the full retail
+ // treatment between UpdatePositions instead of pure kinematics.
+ //
+ // Skipped when rm.CellId == 0 (no UP landed yet — can't build
+ // a SpherePath without a starting cell). One-frame grace until
+ // the first UP arrives; harmless because the entity is
+ // server-freshly-spawned at a valid Z anyway.
+ if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
+ {
+ // Sphere dims match local-player defaults (human Setup
+ // bounds — ~0.48m radius, ~1.2m height). Good enough for
+ // grounded humanoid remotes; can be setup-derived later
+ // if creatures of wildly different sizes need different
+ // collision profiles.
+ var resolveResult = _physicsEngine.ResolveWithTransition(
+ preIntegratePos, postIntegratePos, rm.CellId,
+ sphereRadius: 0.48f,
+ sphereHeight: 1.2f,
+ stepUpHeight: 2.0f, // retail default for unknown remotes
+ stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
+ isOnGround: true, // remotes are forced OnWalkable above
+ body: rm.Body); // persist ContactPlane across frames for slope tracking
+
+ rm.Body.Position = resolveResult.Position;
+ if (resolveResult.CellId != 0)
+ rm.CellId = resolveResult.CellId;
+ }
ae.Entity.Position = rm.Body.Position;
ae.Entity.Rotation = rm.Body.Orientation;
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index d3fc908..9c93915 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -87,6 +87,32 @@ public sealed class PhysicsBody
/// Ground contact-plane normal (+0x130/134/138).
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
+ // ── persisted contact-plane state (retail PhysicsObj fields) ───────────
+ //
+ // Retail's PhysicsObj carries its last contact plane FORWARD across frames.
+ // When PhysicsObj.transition(oldPos, newPos) creates a new Transition, it
+ // seeds CollisionInfo.ContactPlane from these fields via InitContactPlane
+ // (see ACE PhysicsObj.cs:2586-2621 get_object_info). That seed is what lets
+ // AdjustOffset project horizontal velocity onto the slope surface on the
+ // first step — without it, a freshly-allocated Transition has no plane,
+ // so running on a slope proceeds purely horizontally and the sphere
+ // floats above the terrain (step-down budget is only ~4 cm per tick).
+ //
+ // ACE field names: PhysicsObj.ContactPlane / ContactPlaneCellID.
+
+ /// Whether currently holds a valid plane.
+ public bool ContactPlaneValid { get; set; }
+
+ /// Most recent walkable contact plane (world-space).
+ /// Updated at the end of every ResolveWithTransition call that found ground.
+ public System.Numerics.Plane ContactPlane { get; set; }
+
+ /// Full 32-bit cell id of the cell that owns .
+ public uint ContactPlaneCellId { get; set; }
+
+ /// Whether the contact plane is a water surface (affects step behavior).
+ public bool ContactPlaneIsWater { get; set; }
+
/// Elasticity coefficient (+0xB0).
public float Elasticity { get; set; } = 0.05f;
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index f966d10..aa5fd2b 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -310,12 +310,33 @@ public sealed class PhysicsEngine
/// Subdivides movement into sphere-radius steps, tests terrain collision
/// at each step, handles step-down for ground contact.
/// Falls back to the simple if the transition fails.
+ ///
+ ///
+ /// is optional but highly recommended for movement
+ /// that runs across multiple frames. When provided, the previous frame's
+ /// contact plane is copied INTO the transition's CollisionInfo (mirroring
+ /// retail's PhysicsObj.get_object_info → InitContactPlane at
+ /// PhysicsObj.cs:2598-2604). That seed is critical for slope
+ /// tracking: AdjustOffset projects the Euler offset onto the plane
+ /// so horizontal velocity acquires the correct Z component for the slope,
+ /// preventing the character from floating on downhill runs where the
+ /// per-frame descent exceeds the 4 cm step-down budget.
+ ///
+ ///
+ ///
+ /// On return, the plane discovered during this call is written BACK to
+ /// , so the next frame's transition starts with
+ /// an up-to-date plane seed. Callers without a persistent body (tests,
+ /// one-shot movements) can pass null and accept the first-frame
+ /// hiccup.
+ ///
///
public ResolveResult ResolveWithTransition(
Vector3 currentPos, Vector3 targetPos, uint cellId,
float sphereRadius, float sphereHeight,
float stepUpHeight, float stepDownHeight,
- bool isOnGround)
+ bool isOnGround,
+ PhysicsBody? body = null)
{
var transition = new Transition();
transition.ObjectInfo.StepUpHeight = stepUpHeight;
@@ -325,6 +346,19 @@ public sealed class PhysicsEngine
if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
+ // Seed the transition's CollisionInfo with the previous frame's
+ // contact plane (retail PhysicsObj field). Without this, every
+ // ResolveWithTransition call starts with a fresh plane, AdjustOffset's
+ // "Have a contact plane" branch never fires, and slope projection
+ // never happens.
+ if (body is not null && body.ContactPlaneValid)
+ {
+ transition.CollisionInfo.SetContactPlane(
+ body.ContactPlane,
+ body.ContactPlaneCellId,
+ body.ContactPlaneIsWater);
+ }
+
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
bool ok = transition.FindTransitionalPosition(this);
@@ -332,6 +366,31 @@ public sealed class PhysicsEngine
var sp = transition.SpherePath;
var ci = transition.CollisionInfo;
+ // Persist the resulting contact plane state back to the body so the
+ // next frame's transition can seed from it. Uses LastKnownContactPlane
+ // when current is invalid (e.g., airborne this frame), matching retail.
+ if (body is not null)
+ {
+ if (ci.ContactPlaneValid)
+ {
+ body.ContactPlaneValid = true;
+ body.ContactPlane = ci.ContactPlane;
+ body.ContactPlaneCellId = ci.ContactPlaneCellId;
+ body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
+ }
+ else if (ci.LastKnownContactPlaneValid)
+ {
+ body.ContactPlaneValid = true;
+ body.ContactPlane = ci.LastKnownContactPlane;
+ body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
+ body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
+ }
+ else
+ {
+ body.ContactPlaneValid = false;
+ }
+ }
+
if (ok)
{
bool onGround = ci.ContactPlaneValid