acdream/src/AcDream.App/Input/PlayerMovementController.cs
Erik 93cbabbc87 fix(physics): full retail per-frame chain for remote motion + persist ContactPlane across frames
Two linked issues both rooted in skipping parts of the retail physics chain.

## 1. Remote staircase on slopes — Euler never integrated between UPs

TickAnimations called rm.Body.update_object(now) for remote integration, but
PhysicsBody.update_object gates on MinQuantum = 1/30s (retail FUN_00515020
early-return). At our 60fps render tick (~16 ms), deltaTime < MinQuantum on
almost every frame → early return AND LastUpdateTime never advances → position
effectively never integrates. Remote Position changed only on UP hard-snap,
producing visible teleport strides uphill (the "staircase" the user reported).

Fix: call UpdatePhysicsInternal(dt) directly for the remote tick — the same
pattern PlayerMovementController.cs:358 uses for the local player. Wire
ResolveWithTransition in afterwards so the remote's Euler-advanced position
gets swept through the same retail collision chain (find_env_collisions +
find_obj_collisions + step_down + 6-path BSP dispatcher) that the local
player already goes through.

New field RemoteMotion.CellId tracks the remote's cell across frames; set
from UpdatePosition.p.LandblockId and updated from transition output.

## 2. Local player floating on downhill slopes — ContactPlane not persisted

Running a character down a slope faster than ~0.5 m/s vertical: per-frame
Euler moves feet horizontally (no Z component since velocity is world-XY).
After Euler, feet are above the new-XY terrain. ValidateWalkable takes the
"above surface" branch without setting a contact plane, DoStepDown probes
~4 cm down (the retail StepDownHeight default), fails to find the surface
8-10 cm below, and the character stays at the old Z. Over a sustained
descent this accumulates into a visible hover.

Retail's PhysicsObj carries ContactPlane + ContactPlaneCellID as persistent
fields (ACE PhysicsObj.cs:2598-2604 get_object_info → InitContactPlane).
Each transition call seeds CollisionInfo.ContactPlane from the previous
frame's plane. That seed is what lets AdjustOffset project horizontal
velocity onto the slope surface — so the Euler offset acquires a Z
component matching the slope and the sphere tracks terrain without needing
step-down to do the catch-up every frame.

Fix: add PhysicsBody.ContactPlane* fields mirroring PhysicsObj's. Extend
ResolveWithTransition with an optional `body` parameter; when provided, seed
the transition's CollisionInfo from body.ContactPlane at the start, copy
back (preferring current, falling back to LastKnown) at the end. Both local
(PlayerMovementController) and remote (TickAnimations) pass their body.

Verified live: DIAG samples showed pre/post/resolved Z all exactly equal
before the MinQuantum bypass (Euler frozen). After bypass, deltas dropped
to floating-point noise on slopes for remotes. Local hover on downhill
resolved in separate visual pass.

All 717 tests green. No API breaks (ResolveWithTransition's body param is
optional, backwards-compatible).

Cross-refs:
 - decompile: FUN_00515020 update_object, FUN_005111D0 UpdatePhysicsInternal,
   FUN_005148A0 transition init
 - ACE: PhysicsObj.cs:2586-2621 get_object_info, Transition.cs:613-620 InitContactPlane

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:55:59 +02:00

554 lines
23 KiB
C#

using System;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Input;
/// <summary>
/// Input state for a single frame of player movement.
/// </summary>
public readonly record struct MovementInput(
bool Forward = false,
bool Backward = false,
bool StrafeLeft = false,
bool StrafeRight = false,
bool TurnLeft = false,
bool TurnRight = false,
bool Run = false,
float MouseDeltaX = 0f,
bool Jump = false);
/// <summary>
/// Result of a single frame's movement update.
///
/// <para>
/// <b>Wire vs. local animation command.</b> ACE's <c>MovementData</c>
/// (<c>ACE.Server/Network/Motion/MovementData.cs</c>) only computes
/// <c>interpState.ForwardSpeed</c> for raw <c>WalkForward</c>/
/// <c>WalkBackwards</c> — on every other command the <c>else</c> branch
/// passes through command without setting speed, leaving observers with
/// <c>speed=0</c>. The client therefore has to send <c>WalkForward</c>
/// (with <c>HoldKey.Run</c> for running) and let ACE auto-upgrade to
/// <c>RunForward</c> for broadcast. But the LOCAL view wants the run
/// cycle immediately, so we carry a separate
/// <see cref="LocalAnimationCommand"/> for the player's own renderer.
/// </para>
/// <para>
/// <see cref="IsRunning"/> — true when the player is holding Shift to run.
/// Used by the GameWindow when building the outbound MoveToState's
/// CURRENT_HOLD_KEY (2=Run) vs (1=None).
/// </para>
/// </summary>
public readonly record struct MovementResult(
Vector3 Position,
uint CellId,
bool IsOnGround,
bool MotionStateChanged,
uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …)
uint? SidestepCommand,
uint? TurnCommand,
float? ForwardSpeed,
float? SidestepSpeed,
float? TurnSpeed,
bool IsRunning = false,
uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running)
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame
Vector3? JumpVelocity = null); // world-space launch velocity (sent in jump packet)
/// <summary>
/// Portal-space state for the player movement controller.
/// PortalSpace freezes all movement input while the server is moving the
/// player through a portal — resumed once the destination UpdatePosition
/// arrives and the player is snapped to the new location.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public enum PlayerState { InWorld, PortalSpace }
/// <summary>
/// Per-frame player movement controller. Reads input, drives the
/// ported PhysicsBody + MotionInterpreter, tracks motion state for
/// animation + server messages.
///
/// Architecture:
/// - PhysicsBody owns integration: gravity, friction, sub-stepping,
/// velocity clamping — all from the decompiled retail client.
/// - MotionInterpreter owns the motion state machine: walk/run/jump
/// validation, state tracking, speed constants from the retail dat.
/// - PhysicsEngine.Resolve is still used each frame to snap the player
/// to terrain/cell floor Z and detect ground contact.
/// </summary>
public sealed class PlayerMovementController
{
private readonly PhysicsEngine _physics;
private readonly PhysicsBody _body;
private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie;
public float MouseTurnSensitivity { get; set; } = 0.003f;
/// <summary>
/// Maximum Z increase per movement step before the move is rejected.
/// AC's default StepUpHeight for human characters is ~2 units.
/// Using 5 for the MVP to be forgiving — prevents walking up vertical
/// walls but allows stairs, ramps, and terrain slopes.
/// </summary>
public float StepUpHeight { get; set; } = 5.0f;
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
/// UpdatePosition arrives and the player is snapped to the new cell.
/// While in PortalSpace, Update returns immediately with a zero-movement
/// result so no WASD input or physics is processed.
/// </summary>
public PlayerState State { get; set; } = PlayerState.InWorld;
public float Yaw { get; set; }
public Vector3 Position => _body.Position;
public uint CellId { get; private set; }
public bool IsAirborne => !_body.OnWalkable;
/// <summary>
/// Current vertical (Z-axis) velocity of the physics body.
/// Positive = rising, negative = falling. Exposed for tests and HUD.
/// </summary>
public float VerticalVelocity => _body.Velocity.Z;
/// <summary>Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.</summary>
public Vector3 BodyVelocity => _body.Velocity;
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
private const float JumpChargeRate = 1.0f; // 0→1 over 1 second
// Airborne → grounded transition detection. Flipped on every frame where
// the body transitions from airborne to on-walkable; used by the GameWindow
// to drive the landing animation cycle.
private bool _wasAirborneLastFrame;
// Previous frame's motion commands for change detection.
private uint? _prevForwardCmd;
private uint? _prevSidestepCmd;
private uint? _prevTurnCmd;
private float? _prevForwardSpeed;
private bool _prevRunHold;
private uint? _prevLocalAnimCmd;
// Heartbeat timer.
private float _heartbeatAccum;
public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; }
public PlayerMovementController(PhysicsEngine physics)
{
_physics = physics;
_body = new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
};
// Default skills — tuned toward mid-retail feel (jump ≈ 3m at full charge,
// run rate ≈ 2.4x). Real characters' skills come from PlayerDescription
// (0xF7B0/0x0013) which we don't parse yet; override via env vars:
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
int runSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_RUN_SKILL"), out var rs) ? rs : 200;
int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 200;
_weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill);
_motion = new MotionInterpreter(_body, _weenie);
}
public void SetCharacterSkills(int runSkill, int jumpSkill)
{
_weenie.SetSkills(runSkill, jumpSkill);
}
/// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into
/// <see cref="MotionInterpreter.GetCycleVelocity"/>. When attached,
/// <c>get_state_velocity</c> uses <c>MotionData.Velocity * speedMod</c>
/// as the primary forward-axis drive, keeping the body's world velocity
/// locked to the animation's baked-in root-motion velocity.
///
/// <para>
/// Without this accessor, the decompiled constant path
/// (<c>RunAnimSpeed * ForwardSpeed</c>) is used — matches retail only
/// when the character's MotionTable happens to bake Velocity=4.0 on
/// RunForward, which is true for Humanoid but not for arbitrary
/// creatures. See <see cref="MotionInterpreter.GetCycleVelocity"/>
/// for the full rationale.
/// </para>
///
/// <para>
/// Called once from <c>GameWindow.CreateAnimatedEntity</c> after the
/// player's <c>AnimatedEntity.Sequencer</c> is constructed.
/// </para>
/// </summary>
public void AttachCycleVelocityAccessor(Func<Vector3> accessor)
{
if (accessor is null) throw new ArgumentNullException(nameof(accessor));
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the
/// player's MotionInterpreter. The server broadcasts the real RunRate
/// derived from the character's Run skill; wiring it here ensures
/// get_state_velocity produces the correct speed instead of the default 1.0.
/// </summary>
public void ApplyServerRunRate(float forwardSpeed)
{
_motion.InterpretedState.ForwardSpeed = forwardSpeed;
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
public void SetPosition(Vector3 pos, uint cellId)
{
_body.Position = pos;
CellId = cellId;
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.Velocity = Vector3.Zero;
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
}
public MovementResult Update(float dt, MovementInput input)
{
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
// can detect the frozen state (MotionStateChanged = false, no commands).
if (State == PlayerState.PortalSpace)
{
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: false,
ForwardCommand: null,
SidestepCommand: null,
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
if (input.TurnRight)
Yaw -= MotionInterpreter.WalkAnimSpeed * 0.5f * dt; // ~90°/s
if (input.TurnLeft)
Yaw += MotionInterpreter.WalkAnimSpeed * 0.5f * dt;
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
// Sync the body's orientation quaternion with our Yaw (rotation about Z).
// Convention: Yaw=0 faces +X. Local body +Y is "forward", so we rotate
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
// Determine the dominant forward/backward command and speed.
uint forwardCmd;
float forwardCmdSpeed;
if (input.Forward)
{
forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward;
// When running, use the PlayerWeenie's RunRate as ForwardSpeed.
// The retail server computes this from Run skill + encumbrance and
// broadcasts it in UpdateMotion, but it doesn't echo to the sender.
// We compute locally using the same formula.
if (input.Run && _weenie.InqRunRate(out float runRate))
forwardCmdSpeed = runRate;
else
forwardCmdSpeed = 1.0f;
}
else if (input.Backward)
{
forwardCmd = MotionCommand.WalkBackward;
forwardCmdSpeed = 1.0f;
}
else
{
forwardCmd = MotionCommand.Ready;
forwardCmdSpeed = 1.0f;
}
// Update interpreted motion state (needed for animation + server messages).
_motion.DoMotion(forwardCmd, forwardCmdSpeed);
// Sidestep.
if (input.StrafeRight)
_motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true);
else if (input.StrafeLeft)
_motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true);
else
{
_motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true);
_motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true);
}
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
// persists — gravity pulls Z down, horizontal momentum is preserved.
// Retail AC works this way: you maintain momentum in the air.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
var stateVel = _motion.get_state_velocity();
float localY = 0f;
float localX = 0f;
if (input.Forward)
localY = stateVel.Y;
else if (input.Backward)
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
// Full-speed strafe to match retail sidestep pace.
if (input.StrafeRight)
localX = MotionInterpreter.SidestepAnimSpeed;
else if (input.StrafeLeft)
localX = -MotionInterpreter.SidestepAnimSpeed;
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
// Release to execute: jump(extent) validates + sets JumpExtent,
// then LeaveGround() applies the scaled velocity via get_leave_ground_velocity.
float? outJumpExtent = null;
Vector3? outJumpVelocity = null;
if (input.Jump && _body.OnWalkable)
{
// Spacebar held and on the ground — accumulate charge.
if (!_jumpCharging)
{
_jumpCharging = true;
_jumpExtent = 0f;
}
_jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f);
}
else if (_jumpCharging)
{
// Spacebar released (or left ground during charge) — fire jump.
var jumpResult = _motion.jump(_jumpExtent);
if (jumpResult == WeenieError.None)
{
_motion.LeaveGround();
outJumpExtent = _jumpExtent;
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
}
_jumpCharging = false;
_jumpExtent = 0f;
}
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
var preIntegratePos = _body.Position;
_body.calc_acceleration();
_body.UpdatePhysicsInternal(dt);
var postIntegratePos = _body.Position;
// ── 5. Collision resolution via CTransition sphere-sweep ─────────────
// The Transition system subdivides the movement from pre→post into
// sphere-radius steps, testing terrain collision at each step.
// Falls back to simple Z-snap if transition fails.
var resolveResult = _physics.ResolveWithTransition(
preIntegratePos, postIntegratePos, CellId,
sphereRadius: 0.48f, // human player radius from Setup
sphereHeight: 1.2f, // human player height from Setup
stepUpHeight: StepUpHeight,
stepDownHeight: 0.04f, // retail default
isOnGround: _body.OnWalkable,
body: _body); // persist ContactPlane across frames for slope tracking
// Apply resolved position.
_body.Position = resolveResult.Position;
bool justLanded = false;
if (resolveResult.IsOnGround)
{
if (_body.Velocity.Z <= 0f)
{
// Grounded — snap to resolved position and land.
bool wasAirborne = !_body.OnWalkable;
_body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
_body.calc_acceleration();
if (_body.Velocity.Z < 0f)
_body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f);
if (wasAirborne)
{
_motion.HitGround();
justLanded = true;
}
}
else
{
// Moving upward (jump) — stay airborne even though terrain is below.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
}
else
{
// No ground found — airborne.
_body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
_body.calc_acceleration();
}
_wasAirborneLastFrame = !_body.OnWalkable;
CellId = resolveResult.CellId;
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
float? outForwardSpeed = null;
uint? outSidestepCmd = null;
float? outSidestepSpeed = null;
uint? outTurnCmd = null;
float? outTurnSpeed = null;
// Retail-faithful wire commands. ACE's MovementData constructor only
// computes interpState.ForwardSpeed for WalkForward / WalkBackwards
// (Network/Motion/MovementData.cs:104-119) — for any other command
// the else-branch passes through without setting speed, so observers
// dead-reckon at speed=0. The wire therefore must be:
// - Forward (walk): WalkForward @ 1.0
// - Forward (run): WalkForward @ run_rate + HoldKey.Run
// (ACE auto-upgrades to RunForward for observers)
// - Backward: WalkBackward @ 1.0
// Our own local animation still wants the actual RunForward cycle
// though — that's carried separately in LocalAnimationCommand below.
uint? localAnimCmd = null;
if (input.Forward)
{
outForwardCmd = MotionCommand.WalkForward;
if (input.Run && _weenie.InqRunRate(out float runRate))
{
outForwardSpeed = runRate;
localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward
}
else
{
outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkForward;
}
}
else if (input.Backward)
{
outForwardCmd = MotionCommand.WalkBackward;
outForwardSpeed = 1.0f;
localAnimCmd = MotionCommand.WalkBackward;
}
// Strafe: retail uses speed=1.0 for SideStep (see holtburger
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess
// and made strafing feel lethargic; the retail feel is full-speed
// sidestep matching the walk forward pace.
if (input.StrafeRight)
{
outSidestepCmd = MotionCommand.SideStepRight;
outSidestepSpeed = 1.0f;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 1.0f;
}
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied
// directly to Yaw above and doesn't generate a turn command — if it
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft
// every frame, causing stateChanged=True on every frame and flooding
// the server with MoveToState spam.
if (input.TurnRight)
{
outTurnCmd = MotionCommand.TurnRight;
outTurnSpeed = 1.0f;
}
else if (input.TurnLeft)
{
outTurnCmd = MotionCommand.TurnLeft;
outTurnSpeed = 1.0f;
}
// ── 7. Detect motion state change ─────────────────────────────────────
// Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY
// ForwardSpeed or the run-hold bit changes. If the user is already
// walking (W held), then presses Shift, the outbound wire still has
// ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to
// runRate. Without also tracking speed + hold-key here, no new
// MoveToState is sent — the server keeps thinking the player walks,
// and retail observers render walking animation despite the local
// player's RunForward cycle.
//
// Similarly LocalAnimationCommand change (Walk→Run on local cycle)
// must force a fresh outbound so ACE's BroadcastMovement re-runs
// MovementData(this, moveToState) which only reads ForwardCommand +
// ForwardSpeed + HoldKey to pick between WalkForward vs RunForward
// for remote observers.
bool runHold = input.Run;
bool changed = outForwardCmd != _prevForwardCmd
|| outSidestepCmd != _prevSidestepCmd
|| outTurnCmd != _prevTurnCmd
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold
|| localAnimCmd != _prevLocalAnimCmd;
_prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd;
_prevForwardSpeed = outForwardSpeed;
_prevRunHold = runHold;
_prevLocalAnimCmd = localAnimCmd;
static bool FloatsEqual(float? a, float? b)
{
if (a.HasValue != b.HasValue) return false;
if (!a.HasValue || !b.HasValue) return true;
return System.Math.Abs(a.Value - b.Value) < 1e-4f;
}
// ── 8. Heartbeat timer (only while moving) ────────────────────────────
bool isMoving = outForwardCmd is not null
|| outSidestepCmd is not null
|| outTurnCmd is not null;
if (isMoving)
{
_heartbeatAccum += dt;
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
if (HeartbeatDue) _heartbeatAccum = 0f;
}
else
{
_heartbeatAccum = 0f;
HeartbeatDue = false;
}
return new MovementResult(
Position: Position,
CellId: CellId,
IsOnGround: _body.OnWalkable,
MotionStateChanged: changed,
ForwardCommand: outForwardCmd,
SidestepCommand: outSidestepCmd,
TurnCommand: outTurnCmd,
ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed,
IsRunning: input.Run && input.Forward,
LocalAnimationCommand: localAnimCmd,
JustLanded: justLanded,
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}
}