feat(D6.2a): port CMotionInterp motion normalization + unify local velocity/turn/jump

Ports retail's raw->interpreted motion normalization and routes the local
player's velocity through it, so backward/strafe come from the retail source
instead of hand-mirrored controller code (retires register TS-22 + UN-5).

MotionInterpreter (decomp-verbatim, Ghidra/ACE-cross-checked):
- adjust_motion (0x00528010): WalkBackward->WalkForward (x-0.649999976),
  TurnLeft->TurnRight (x-1), SideStepLeft->SideStepRight (x-1), sidestep scale
  0.5*(WalkAnimSpeed/SidestepAnimSpeed), RunForward early-return, per-channel
  holdkey fallback to CurrentHoldKey.
- apply_run_to_command (0x00527be0): WalkForward->RunForward when speed>0 +
  UNCONDITIONAL *runRate; TurnRight *1.5; SideStepRight *runRate clamp +/-3.0.
- apply_raw_movement (0x005287e0): copy raw->interpreted, adjust the 3 channels
  with per-channel hold keys.
- WalkAnimSpeed unified to the retail-exact 3.11999989f (was 3.12f).

PlayerMovementController: build ONE RawMotionState from MovementInput at
forward_speed=1.0 (apply_run_to_command applies the run rate — a pre-scaled
speed would double-scale), run apply_raw_movement, then take grounded + jump
velocity straight from get_state_velocity (now correct for all directions) and
drive the keyboard turn omega from the interpreted turn_speed (BaseTurnRate x
turn_speed = the same pi/2 formula the remote path uses; numerically identical
to the old TurnRateFor). Deleted the hand-mirrored backward(x-0.65) /
strafe(x+-1.25) formulas in both the grounded and jump blocks. Mouse turn and
the auto-walk path are untouched.

Retail-faithful behavior changes (confirm in smoke): strafe is now
1.25 * ~1.248 * runRate = ~1.56*runRate, clamped via SideStepSpeed<=3.0 so
|v.X|<=3.75 (was 1.25*runRate, no scale/clamp); backward is unchanged
(3.12*0.65*runRate); backward/strafe-left velocity now comes from the pipeline
instead of returning zero. Forward run pace unchanged (4.0*runRate).

Tests: MotionNormalizationTests (17, adjust_motion/apply_run_to_command) +
MotionVelocityPipelineTests (10, apply_raw_movement->get_state_velocity golden
velocities incl. the strafe clamp + turn speeds). Full suite green (3255).

Register: TS-22 + UN-5 deleted (retired); TS-34 added (adjust_motion creature
guard is a no-op — IWeenieObject has no IsCreature; correct for the only caller,
the player). Pseudocode: docs/research/2026-07-01-d6-motion-interp-pseudocode.md.

NEXT D6.2b: the echo-gated wire forward_speed=1.0 question (evidence suggests
ACE relays rather than recomputes; verified via the smoke echo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-01 08:57:17 +02:00
parent 4ed278369b
commit 0f099bb652
6 changed files with 801 additions and 149 deletions

View file

@ -875,6 +875,40 @@ public sealed class PlayerMovementController
TurnSpeed: null);
}
// ── D6.2: normalize input into the interpreted motion state ───────────
// ONE RawMotionState from MovementInput drives the local velocity + turn
// (retail CMotionInterp::apply_raw_movement 0x005287e0). RAW speeds are
// 1.0 — apply_run_to_command applies the run rate, so passing a pre-scaled
// speed would double-scale. adjust_motion normalizes backward →
// WalkForward (×-0.65) and strafe-left → SideStepRight (×-1×1.248), so
// get_state_velocity below is correct for ALL directions (retires the
// hand-mirrored formulas; register TS-22). Skipped during server
// auto-walk, which drives the body itself.
if (!autoWalkConsumedMotion)
{
var axisHold = input.Run ? HoldKey.Run : HoldKey.None;
var raw = new RawMotionState
{
CurrentHoldKey = axisHold,
ForwardCommand = input.Forward ? MotionCommand.WalkForward
: input.Backward ? MotionCommand.WalkBackward
: MotionCommand.Ready,
ForwardHoldKey = (input.Forward || input.Backward) ? axisHold : HoldKey.Invalid,
ForwardSpeed = 1.0f,
SidestepCommand = input.StrafeRight ? MotionCommand.SideStepRight
: input.StrafeLeft ? MotionCommand.SideStepLeft
: 0u,
SidestepHoldKey = (input.StrafeRight || input.StrafeLeft) ? axisHold : HoldKey.Invalid,
SidestepSpeed = 1.0f,
TurnCommand = input.TurnRight ? MotionCommand.TurnRight
: input.TurnLeft ? MotionCommand.TurnLeft
: 0u,
TurnHoldKey = (input.TurnRight || input.TurnLeft) ? axisHold : HoldKey.Invalid,
TurnSpeed = 1.0f,
};
_motion.apply_raw_movement(raw);
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
@ -886,11 +920,21 @@ public sealed class PlayerMovementController
// Effective: walking ≈ 90°/s, running ≈ 135°/s.
// Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally
// close to retail walking but no run differentiation.
float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run);
if (input.TurnRight)
Yaw -= keyboardTurnRate * dt;
if (input.TurnLeft)
Yaw += keyboardTurnRate * dt;
// D6.2: local keyboard turn rate now comes from the interpreted turn
// state (adjust_motion normalized TurnLeft→TurnRight with sign +
// apply_run_to_command ×RunTurnFactor when running). omega.Z =
// BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote
// path uses (GameWindow ObservedOmega seed). Numerically identical to the
// former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged.
// Skipped during auto-walk (server drives the turn). Mouse turn stays a
// direct Yaw delta (deliberately generates no turn command — avoids
// MoveToState spam from mouse jitter).
if (!autoWalkConsumedMotion
&& _motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{
Yaw -= RemoteMoveToDriver.BaseTurnRateRadPerSec
* _motion.InterpretedState.TurnSpeed * dt;
}
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded.
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
@ -910,53 +954,10 @@ public sealed class PlayerMovementController
// (Ready/Stand) velocity, freezing the body.
if (!autoWalkConsumedMotion)
{
// 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);
}
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
// populated + normalized for all directions.
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
@ -965,37 +966,13 @@ public sealed class PlayerMovementController
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
// D6.2: velocity for ALL directions comes from the normalized
// interpreted state — backward (WalkForward ×-0.65) and strafe-left
// (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
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));
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz));
}
} // end of `if (!autoWalkConsumedMotion)` — section 2
@ -1029,56 +1006,13 @@ public sealed class PlayerMovementController
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);
// D6.2: get_state_velocity() is now correct for all directions
// (apply_raw_movement normalized backward/strafe above), so the
// jump-launch velocity is get_state_velocity() + the vertical jump
// component. Retires the duplicated hand-mirrored formulas that
// existed only until adjust_motion was ported (register TS-22).
var jumpVel = _motion.get_state_velocity();
outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz);
// Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ)
// to the body for backward/strafe-left (same get_state_velocity