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