Closes a multi-bug knot in player motion outbound + remote inbound,
discovered via cdb live trace of retail (2026-05-01) and follow-up
visual verification.
Outbound (acdream → ACE):
- JumpAction velocity is BODY-LOCAL, not world (per retail
CPhysicsObj::get_local_physics_velocity at 0x00512140 + ACE
Player.HandleActionJump's set_local_velocity call). Was sending
world; observers saw jump rotated by player yaw.
- Capture get_jump_v_z BEFORE LeaveGround() — the latter resets
JumpExtent to 0, after which get_jump_v_z returned 0. Was sending
Z=0 in every JumpAction.
- Backward/strafe-left jumps lost their horizontal velocity because
LeaveGround → get_state_velocity returns zero for non-canonical
motion (faithful to retail's FUN_00528960; retail papers over via
adjust_motion translation, not yet ported). Compute the correct
body-local launch velocity from input directly and push it back
into the body so local prediction matches what we send.
- IsRunning HoldKey was gated on `input.Run && input.Forward`, so
strafe-run and backward-run incorrectly broadcast as walk to
observers — ACE then animated walk + dead-reckoned at walk speed
while server position moved at run speed (visible as observer
lag). Fixed: gate on any active directional axis.
- AutonomousPosition heartbeat 0.2s → 1.0s to match holtburger's
AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the ~1Hz observed in
retail trace.
- Heartbeat now fires while in-world regardless of motion state
(matches holtburger + retail's transient_state-based gate, not
motion-based). Pre-fix the at-rest heartbeat was suppressed.
Inbound (ACE → acdream, remote retail player):
- Remote backward walk arrives as cmd=WalkForward + speed=-1.91
(retail's adjust_motion'd form). Two bugs were stacking:
1. AnimationSequencer fast-path returned without updating when
sign(speedMod) flipped while motion stayed equal — kept playing
forward at old positive framerate. Fixed: bypass fast-path on
sign change so the full re-setup runs.
2. GameWindow clamped negative speedMod to 1.0 when stuffing
InterpretedState.ForwardSpeed, making get_state_velocity
produce forward velocity. Fixed: pass speedMod through verbatim
so the dead-reckoning body translates backward.
Issue #38 filed: 30Hz physics tick produces a chase-camera smoothness
regression at 60+ FPS render. Standard render-time interpolation is
the recommended fix (separate phase).
Findings + comparison vs retail/holtburger:
docs/research/2026-05-01-retail-motion-trace/findings.md
docs/research/2026-05-01-retail-motion-trace/fixes.md
TODO: port retail's adjust_motion (FUN_00528010) properly so
get_state_velocity works for all directions natively — would let us
drop the workaround in PlayerMovementController jump path and the
clamp in GameWindow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
884 lines
42 KiB
C#
884 lines
42 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)
|
||
// K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation
|
||
// sequencer. Decoupled from ForwardSpeed so the wire can keep sending
|
||
// 1.0 for WalkBackward (ACE-compatible) while the animation plays at
|
||
// runRate × so the cycle visually matches the run-speed velocity.
|
||
// Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run
|
||
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
|
||
float LocalAnimationSpeed = 1f,
|
||
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); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast.
|
||
|
||
/// <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.
|
||
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
|
||
/// level). Setting this too high lets the player teleport up small
|
||
/// buildings via the step-up scan finding any walkable polygon within
|
||
/// reach (Bug 3 in L.2.3 testing — walking into a steep slope mounted
|
||
/// the building's flat top instead of sliding off the slope).
|
||
/// Authoritative source is the player's <c>Setup.StepUpHeight</c> set
|
||
/// in GameWindow.cs at world-entry time.
|
||
/// </summary>
|
||
public float StepUpHeight { get; set; } = 0.4f;
|
||
|
||
/// <summary>
|
||
/// L.2.3a (2026-04-29): how far below the foot the step-down probe
|
||
/// reaches when transitioning between surfaces. Retail's
|
||
/// <c>step_down_height</c> for human characters is ~0.4 m. With the
|
||
/// previous 4 cm hardcoded value, walking off the top of a stair onto
|
||
/// the ground 25 cm below produced a one-frame contact-plane gap — the
|
||
/// animation system briefly flickered to falling.
|
||
/// </summary>
|
||
public float StepDownHeight { get; set; } = 0.4f;
|
||
|
||
/// <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;
|
||
// K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is
|
||
// not legible in the named decomp (the divisor was clobbered in
|
||
// GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s
|
||
// (full charge in 0.5s) feels matches retail muscle memory better
|
||
// than the previous 1.0/s — a tap gives a noticeable hop, half-hold
|
||
// a meaningful jump, full-hold the maximum extent. The vertical
|
||
// velocity formula itself (height × 19.6 → vz) is unchanged and
|
||
// matches retail byte-for-byte; only the time-to-fill is faster.
|
||
private const float JumpChargeRate = 2.0f;
|
||
|
||
// 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.
|
||
// Cadence is 1.0 sec to match holtburger's
|
||
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace
|
||
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
|
||
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
|
||
// but wasteful and probably looked like jitter to observers.
|
||
private float _heartbeatAccum;
|
||
public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
|
||
public bool HeartbeatDue { get; private set; }
|
||
|
||
// L.5 retail physics-tick gate (2026-04-30).
|
||
//
|
||
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
|
||
// MinQuantum (1/30s) sized integration steps, SKIPPING entirely when
|
||
// accumulated dt is below MinQuantum. The retail debugger trace
|
||
// confirmed this: UpdatePhysicsInternal fires only ~61% as often as
|
||
// update_object — i.e., retail's effective physics tick rate is 30Hz
|
||
// even when the renderer runs at 60+Hz.
|
||
//
|
||
// Without this gate our acdream integrates at the full render rate
|
||
// (60+Hz), which compresses bounce-energy / gravity-tangent
|
||
// accumulation into half the time. Per-frame V grows ~2x faster than
|
||
// retail's. On a steep-slope tangent that produces the wedge: V grows
|
||
// tangent + huge while position reverts each frame, body locks in
|
||
// place. Retail's slower integration cadence (and larger per-tick
|
||
// position deltas) lets the body geometrically escape the tangent.
|
||
//
|
||
// Source: retail debugger trace 2026-04-30
|
||
// update_object = 40,960 calls
|
||
// UpdatePhysicsInternal = 25,087 calls (61%)
|
||
// ratio implies 39% of frames return early via the MinQuantum gate.
|
||
//
|
||
// ACE: PhysicsObj.UpdateObject (Physics.cs).
|
||
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
|
||
private float _physicsAccum;
|
||
|
||
public PlayerMovementController(PhysicsEngine physics)
|
||
{
|
||
_physics = physics;
|
||
|
||
_body = new PhysicsBody
|
||
{
|
||
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
||
};
|
||
|
||
// Default skills — tuned toward mid-retail feel. Real characters'
|
||
// skills come from PlayerDescription (0xF7B0/0x0013) which we don't
|
||
// parse yet; override via env vars:
|
||
// ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL
|
||
// K-fix6 (2026-04-26): bumped default jump skill from 200 → 300.
|
||
// Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1):
|
||
// skill=200 → 3.01m max (felt too low — user complaint)
|
||
// skill=300 → 4.21m max (closer to a typical retail mid-tier
|
||
// character's "I can clear that fence" hop)
|
||
// Until #7 ships and PlayerDescription gives us the server's real
|
||
// skill, this default is the right "feels like retail" baseline.
|
||
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 : 300;
|
||
_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;
|
||
// K-fix3 (2026-04-26): backward also honors Run. Without
|
||
// this, holding X with Run=true (default) still produced
|
||
// walk-tier backward speed because forwardCmdSpeed was
|
||
// hardcoded to 1.0. Now scale by runRate the same way
|
||
// RunForward does.
|
||
if (input.Run && _weenie.InqRunRate(out float runRateBack))
|
||
forwardCmdSpeed = runRateBack;
|
||
else
|
||
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;
|
||
|
||
// K-fix3 (2026-04-26): unified run-multiplier for backward
|
||
// + strafe. Forward already scales correctly because it uses
|
||
// stateVel.Y (which the motion state machine fed runRate
|
||
// into via DoMotion). Backward + strafe bypass the state
|
||
// machine and hardcoded speed; previously they capped at
|
||
// walk speed regardless of Run, which made the ~2.4×
|
||
// forward-vs-back/strafe ratio feel wrong. Now both scale
|
||
// with the same runRate the forward branch uses.
|
||
float runMul = 1.0f;
|
||
if (input.Run && _weenie.InqRunRate(out float vrr))
|
||
runMul = vrr;
|
||
|
||
if (input.Forward)
|
||
localY = stateVel.Y;
|
||
else if (input.Backward)
|
||
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul);
|
||
|
||
// Strafe scales with the same runMul so sidestep matches
|
||
// the forward pace at run speed (retail uses speed=1.0 for
|
||
// SideStep + the same hold-key-driven run/walk multiplier).
|
||
if (input.StrafeRight)
|
||
localX = MotionInterpreter.SidestepAnimSpeed * runMul;
|
||
else if (input.StrafeLeft)
|
||
localX = -MotionInterpreter.SidestepAnimSpeed * runMul;
|
||
|
||
_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)
|
||
{
|
||
// Capture jump_v_z BEFORE LeaveGround() — that call resets
|
||
// JumpExtent back to 0 (faithful to retail's FUN_00529710),
|
||
// after which get_jump_v_z() returns 0 because the extent
|
||
// gate at the top of the function fires.
|
||
float jumpVz = _motion.get_jump_v_z();
|
||
_motion.LeaveGround();
|
||
outJumpExtent = _jumpExtent;
|
||
// BODY-LOCAL jump-launch velocity, computed directly from input.
|
||
//
|
||
// Why not read _body.Velocity? Because _motion.LeaveGround()
|
||
// routes through get_leave_ground_velocity → get_state_velocity,
|
||
// which is a faithful port of retail's FUN_00528960. Retail's
|
||
// version only handles WalkForward (0x45000005) / RunForward
|
||
// (0x44000007) / SideStepRight (0x6500000F); WalkBackwards
|
||
// and SideStepLeft return zero. Retail papers over this in
|
||
// adjust_motion (FUN_00528010) by translating
|
||
// WalkBackwards → WalkForward + speed × -0.65
|
||
// SideStepLeft → SideStepRight + speed × -1
|
||
// before they reach InterpretedState — but we don't yet port
|
||
// adjust_motion, so InterpretedState holds the un-translated
|
||
// command and get_state_velocity returns (0,0,0) for it.
|
||
// LeaveGround then writes (0,0,jumpZ) to the body, wiping the
|
||
// correct strafe/backward velocity the controller had just set
|
||
// a few lines up. Result: backward/strafe jumps go straight up.
|
||
//
|
||
// Until adjust_motion is ported, we mirror the grounded-velocity
|
||
// computation from the block above and stuff the result into
|
||
// outJumpVelocity directly. Local frame: +Y forward, +X right,
|
||
// +Z up — matches retail's body-frame convention. Server
|
||
// rotates body→world on receive, so observers see the jump
|
||
// in the correct world direction.
|
||
float jumpRunMul = 1.0f;
|
||
if (input.Run && _weenie.InqRunRate(out float jvrr))
|
||
jumpRunMul = jvrr;
|
||
|
||
// Forward uses get_state_velocity (which knows Walk vs Run vs
|
||
// animation-cycle pacing). Backward / Strafe use the same
|
||
// hardcoded scaled formulas the grounded-velocity block above
|
||
// uses (lines 397-408).
|
||
float localY = 0f;
|
||
if (input.Forward)
|
||
{
|
||
var stateVel = _motion.get_state_velocity();
|
||
localY = stateVel.Y;
|
||
}
|
||
else if (input.Backward)
|
||
{
|
||
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul);
|
||
}
|
||
|
||
float localX = 0f;
|
||
if (input.StrafeRight)
|
||
localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
|
||
else if (input.StrafeLeft)
|
||
localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul;
|
||
|
||
outJumpVelocity = new Vector3(localX, localY, jumpVz);
|
||
|
||
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
|
||
// to the body for backward/strafe-left (same get_state_velocity
|
||
// zero-for-non-canonical-motion bug as on the wire side).
|
||
// Push the corrected body-local velocity back so the local
|
||
// client renders the jump in the same world direction the
|
||
// server is broadcasting to observers. Same vector we just
|
||
// sent in JumpAction — local + remote stay in sync.
|
||
_body.set_local_velocity(outJumpVelocity.Value);
|
||
}
|
||
_jumpCharging = false;
|
||
_jumpExtent = 0f;
|
||
}
|
||
|
||
// ── 4. Integrate physics (gravity, friction, sub-stepping) ────────────
|
||
//
|
||
// L.5 retail-physics-tick gate (2026-04-30): retail's CPhysicsObj::
|
||
// update_object skips integration when accumulated dt is below
|
||
// MinQuantum (1/30 s). Effective physics rate is 30 Hz even at 60+ Hz
|
||
// render. We accumulate per-frame dt and only integrate (with the
|
||
// accumulated dt) when the threshold is reached. See _physicsAccum
|
||
// declaration for the full retail trace evidence.
|
||
var preIntegratePos = _body.Position;
|
||
_physicsAccum += dt;
|
||
|
||
if (_physicsAccum > PhysicsBody.HugeQuantum)
|
||
{
|
||
// Stale frame (debugger break, GC pause). Discard accumulated dt.
|
||
_physicsAccum = 0f;
|
||
}
|
||
else if (_physicsAccum >= PhysicsBody.MinQuantum)
|
||
{
|
||
// Integrate accumulated dt, clamped to MaxQuantum so a long
|
||
// pause doesn't produce one giant integration step.
|
||
float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum);
|
||
_body.calc_acceleration();
|
||
_body.UpdatePhysicsInternal(tickDt);
|
||
_physicsAccum -= tickDt;
|
||
}
|
||
// Else: dt below MinQuantum threshold — skip integration. Position
|
||
// and velocity remain unchanged; Resolve below runs as a zero-distance
|
||
// sphere sweep (no collision possible) and the rest of the frame
|
||
// (motion commands, animation, return) runs normally.
|
||
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: StepDownHeight, // L.2.3a: from Setup.StepDownHeight
|
||
isOnGround: _body.OnWalkable,
|
||
body: _body, // persist ContactPlane across frames for slope tracking
|
||
// L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes
|
||
// EdgeSlide, and PhysicsObj.get_object_info copies that bit into
|
||
// OBJECTINFO. Keep it explicit here so edge/cliff handling runs
|
||
// under the same flag profile as retail player movement.
|
||
//
|
||
// Commit C 2026-04-29 — local player is always IsPlayer.
|
||
// The PK/PKLite/Impenetrable bits come from PlayerDescription's
|
||
// PlayerKillerStatus property; not yet parsed (non-PK pair → walks
|
||
// through other non-PK players, which is retail's default for
|
||
// ACE's character creation defaults too).
|
||
moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide);
|
||
|
||
// L.4-diag (2026-04-30): trace position transitions so we can see
|
||
// whether the body is actually moving frame-to-frame on the steep
|
||
// roof, or whether it's frozen at the impact point.
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
|
||
&& resolveResult.CollisionNormalValid)
|
||
{
|
||
Console.WriteLine(
|
||
$"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " +
|
||
$"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " +
|
||
$"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " +
|
||
$"isOnGround={resolveResult.IsOnGround}");
|
||
}
|
||
|
||
// Apply resolved position.
|
||
_body.Position = resolveResult.Position;
|
||
|
||
// L.3a (2026-04-30): retail wall-bounce / velocity reflection.
|
||
//
|
||
// Retail's CPhysicsObj::handle_all_collisions runs after every
|
||
// SetPositionInternal. It reads the wall normal that the
|
||
// transition's slide computed and reflects the body's velocity:
|
||
//
|
||
// v_new = v - (1 + elasticity) * dot(v, n) * n
|
||
//
|
||
// This is what gives retail its "bouncy" feel — fast head-on
|
||
// jumps push the player back from the wall, glancing angles
|
||
// produce a small deflection. acdream's transition resolver
|
||
// SLID position correctly but never updated velocity, so the
|
||
// player kept driving into walls until the controller's input
|
||
// changed direction. Felt sticky / fragile.
|
||
//
|
||
// Suppression rule (apply_bounce): grounded movement on a wall
|
||
// SHOULDN'T bounce — sliding along a corridor is expected. Only
|
||
// airborne wall hits reflect. Mirrors retail's `var_10_1` guard
|
||
// and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
|
||
//
|
||
// Inelastic flag (spell projectiles, missiles) zeros velocity
|
||
// entirely instead of reflecting. The player never has it set.
|
||
//
|
||
// Sources:
|
||
// acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
|
||
// acclient.h:2834 (INELASTIC_PS = 0x20000)
|
||
// ACE PhysicsObj.cs:2656-2721 (line-for-line port)
|
||
// PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
|
||
if (resolveResult.CollisionNormalValid)
|
||
{
|
||
bool prevOnWalkable = _body.OnWalkable;
|
||
bool nowOnWalkable = resolveResult.IsOnGround;
|
||
|
||
// apply_bounce: bounce ONLY when the body stays airborne both
|
||
// before and after this step. That is: jumping into a wall
|
||
// mid-flight, hitting a ceiling, etc. Specifically NOT:
|
||
//
|
||
// - prev grounded + now grounded → wall-slide along corridor
|
||
// (bounce would feel sticky on every wall touch).
|
||
// - prev airborne + now grounded → terrain landing
|
||
// (terrain normal is mostly +Z; reflecting downward velocity
|
||
// would push the body upward and prevent the landing snap
|
||
// from firing — player perpetually micro-bouncing on the
|
||
// floor instead of resting).
|
||
// - prev grounded + now airborne → walked off cliff
|
||
// (gravity should take over, not lateral bounce).
|
||
//
|
||
// Sledding mode reverts to retail's broader rule (bounce
|
||
// unless both grounded), since sledding intentionally bounces
|
||
// off ramps.
|
||
//
|
||
// This is more conservative than retail's strict
|
||
// `!(prev && now && !sledding)` rule — retail bounces on
|
||
// landing too, but at elasticity 0.05 the visual effect is
|
||
// imperceptible there. acdream's per-frame architecture
|
||
// amplifies the artifact (the post-reflection upward Z
|
||
// defeats the controller's `Velocity.Z <= 0` landing-snap
|
||
// gate), so we suppress it on landing to avoid the
|
||
// micro-bounce death spiral.
|
||
bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
|
||
? !(prevOnWalkable && nowOnWalkable)
|
||
: (!prevOnWalkable && !nowOnWalkable);
|
||
|
||
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
|
||
bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
|
||
if (diagSteep && resolveResult.CollisionNormalValid)
|
||
{
|
||
var n0 = resolveResult.CollisionNormal;
|
||
var v0 = _body.Velocity;
|
||
Console.WriteLine(
|
||
$"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " +
|
||
$"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " +
|
||
$"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " +
|
||
$"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " +
|
||
$"dot={Vector3.Dot(v0, n0):F3} " +
|
||
$"isOnGround={resolveResult.IsOnGround}");
|
||
}
|
||
|
||
if (applyBounce)
|
||
{
|
||
if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
|
||
{
|
||
// Full stop on impact. Spell projectiles / missiles.
|
||
_body.Velocity = Vector3.Zero;
|
||
}
|
||
else
|
||
{
|
||
var v = _body.Velocity;
|
||
var n = resolveResult.CollisionNormal;
|
||
float dotVN = Vector3.Dot(v, n);
|
||
if (dotVN < 0f)
|
||
{
|
||
// Reflect the into-wall component back out.
|
||
// Player elasticity is 0.05 → 105% of perpendicular
|
||
// velocity reflects (subtle bounce).
|
||
float k = -(dotVN * (_body.Elasticity + 1f));
|
||
_body.Velocity = v + n * k;
|
||
|
||
if (diagSteep)
|
||
{
|
||
var v1 = _body.Velocity;
|
||
Console.WriteLine(
|
||
$"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (always while in-world, not just while moving) ─
|
||
// Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
|
||
// motion state (gated only by has_autonomous_position_sync_target).
|
||
// Retail's CommandInterpreter::SendPositionEvent gates on
|
||
// transient_state (Contact + OnWalkable + valid Position), not on
|
||
// motion. The pre-fix isMoving gate stopped acdream from heart-beating
|
||
// at rest, which left observers with stale last-known positions during
|
||
// long idle periods. PortalSpace (handled at the top of Update via
|
||
// early return) skips Update entirely, so reaching this line implies
|
||
// we're in a valid in-world pose.
|
||
_heartbeatAccum += dt;
|
||
HeartbeatDue = _heartbeatAccum >= HeartbeatInterval;
|
||
if (HeartbeatDue) _heartbeatAccum = 0f;
|
||
|
||
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
|
||
// should match the actual movement speed. For Forward+Run this is
|
||
// already runRate (it equals ForwardSpeed). For Backward+Run and
|
||
// Strafe+Run it must be runRate too even though the wire keeps
|
||
// those at 1.0. Picking runMul (already computed above) keeps the
|
||
// math in one place.
|
||
bool anyDirectional = input.Forward || input.Backward
|
||
|| input.StrafeLeft || input.StrafeRight;
|
||
float localAnimSpeed = (input.Run && anyDirectional)
|
||
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
|
||
: 1f;
|
||
|
||
return new MovementResult(
|
||
Position: Position,
|
||
CellId: CellId,
|
||
IsOnGround: _body.OnWalkable,
|
||
MotionStateChanged: changed,
|
||
ForwardCommand: outForwardCmd,
|
||
SidestepCommand: outSidestepCmd,
|
||
TurnCommand: outTurnCmd,
|
||
ForwardSpeed: outForwardSpeed,
|
||
SidestepSpeed: outSidestepSpeed,
|
||
TurnSpeed: outTurnSpeed,
|
||
// Run hold-key applies to ANY active directional axis, not just
|
||
// forward (per holtburger's build_motion_state_raw_motion_state:
|
||
// "uses the same value for every active per-axis hold key"). The
|
||
// pre-fix condition `input.Run && input.Forward` made strafe-run
|
||
// and backward-run incorrectly broadcast as walk to observers,
|
||
// who then animated walk + dead-reckoned at walk speed while the
|
||
// server position moved at run speed — visible as observer lag.
|
||
IsRunning: input.Run && anyDirectional,
|
||
LocalAnimationCommand: localAnimCmd,
|
||
LocalAnimationSpeed: localAnimSpeed,
|
||
JustLanded: justLanded,
|
||
JumpExtent: outJumpExtent,
|
||
JumpVelocity: outJumpVelocity);
|
||
}
|
||
}
|