fix(motion): jump direction, AutoPos cadence, backward/strafe wire & anim
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>
This commit is contained in:
parent
09e013b7bd
commit
17a9ff1158
6 changed files with 691 additions and 22 deletions
|
|
@ -61,7 +61,7 @@ public readonly record struct MovementResult(
|
|||
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); // world-space launch velocity (sent in jump packet)
|
||||
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.
|
||||
|
|
@ -168,8 +168,13 @@ public sealed class PlayerMovementController
|
|||
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 = 0.2f; // 200ms
|
||||
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).
|
||||
|
|
@ -428,9 +433,72 @@ public sealed class PlayerMovementController
|
|||
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;
|
||||
outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it
|
||||
// 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;
|
||||
|
|
@ -762,21 +830,19 @@ public sealed class PlayerMovementController
|
|||
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;
|
||||
}
|
||||
// ── 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
|
||||
|
|
@ -801,7 +867,14 @@ public sealed class PlayerMovementController
|
|||
ForwardSpeed: outForwardSpeed,
|
||||
SidestepSpeed: outSidestepSpeed,
|
||||
TurnSpeed: outTurnSpeed,
|
||||
IsRunning: input.Run && input.Forward,
|
||||
// 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue