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

@ -251,8 +251,10 @@ public interface IWeenieObject
public sealed class MotionInterpreter
{
// ── animation speed constants (from ACE / confirmed by decompile globals) ─
/// <summary>Walk animation base speed (_DAT_007c96e4 family).</summary>
public const float WalkAnimSpeed = 3.12f;
/// <summary>Walk animation base speed. Retail-exact (.rdata 0x007c891c =
/// 3.11999989f); unified for both <see cref="get_state_velocity"/> and
/// <see cref="adjust_motion"/>'s sidestep scale in D6.2.</summary>
public const float WalkAnimSpeed = 3.11999989f;
/// <summary>Run animation base speed (_DAT_007c96e0 family).</summary>
public const float RunAnimSpeed = 4.0f;
/// <summary>Sidestep animation base speed (_DAT_007c96e8 family).</summary>
@ -264,6 +266,34 @@ public sealed class MotionInterpreter
/// <summary>Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f).</summary>
public const float MaxJumpExtent = 1.0f;
/// <summary>
/// Backward-speed multiplier applied by <see cref="adjust_motion"/> when
/// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910).
/// Retail-exact value; do not round to 0.65f.
/// </summary>
public const float BackwardsFactor = 0.649999976f;
/// <summary>
/// Turn speed multiplier applied by <see cref="apply_run_to_command"/> for
/// TurnRight when the hold key is Run (retail apply_run_to_command, no
/// run-rate scaling and no clamp — just a flat ×1.5).
/// </summary>
public const float RunTurnFactor = 1.5f;
/// <summary>
/// Symmetric clamp on sidestep interpreted speed applied by
/// <see cref="apply_run_to_command"/> for SideStepRight when running
/// (retail apply_run_to_command SideStepRight clamp, ±3.0).
/// </summary>
public const float MaxSidestepAnimRate = 3.0f;
/// <summary>
/// Retail-exact sidestep scale factor used by <see cref="adjust_motion"/>
/// when remapping to SideStepRight (retail SidestepFactor = 0.5).
/// </summary>
public const float SidestepFactor = 0.5f;
// ── fields (matching struct layout from acclient_function_map.md) ─────────
/// <summary>The physics body this interpreter controls (struct offset +0x08).</summary>
@ -284,6 +314,16 @@ public sealed class MotionInterpreter
/// <summary>Stored run rate from last successful InqRunRate call (offset +0x7C).</summary>
public float MyRunRate = 1.0f;
/// <summary>
/// The physics object's currently-held movement key (retail
/// <c>this->raw_state.current_holdkey</c>). <see cref="adjust_motion"/>
/// falls back to this field when the per-channel holdKey argument passed
/// in is <see cref="HoldKey.Invalid"/>. D6.2 (the raw-state orchestrator)
/// is responsible for keeping this in sync with the raw motion state;
/// default <see cref="HoldKey.None"/> is a safe no-op until then.
/// </summary>
public HoldKey CurrentHoldKey = HoldKey.None;
/// <summary>True when crouching-in-place for a standing long jump (offset +0x70).</summary>
public bool StandingLongJump;
@ -647,6 +687,191 @@ public sealed class MotionInterpreter
return velocity;
}
// ── FUN_00528010 — adjust_motion ──────────────────────────────────────────
/// <summary>
/// Normalize one raw motion channel (forward, sidestep, or turn) into its
/// canonical interpreted form: fold the "negative" commands (WalkBackward,
/// TurnLeft, SideStepLeft) into their positive counterpart with a negated/
/// scaled speed, then apply the Run hold-key promotion.
///
/// <para>
/// Decompiled logic (FUN_00528010, <c>docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305343-305400</c>):
/// <code>
/// if weenie != null and !weenie.IsCreature(): return
/// switch cmd:
/// RunForward: return // no scale, NO holdkey path
/// WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor
/// TurnLeft: cmd = TurnRight; speed *= -1
/// SideStepLeft: cmd = SideStepRight; speed *= -1
/// if cmd == SideStepRight:
/// speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // ≈ 1.24799995
/// if holdKey == Invalid: holdKey = raw.current_holdkey
/// if holdKey == Run: apply_run_to_command(ref cmd, ref speed)
/// </code>
/// </para>
///
/// <para>
/// GOTCHA: RunForward early-returns before the sidestep scale AND before
/// the hold-key path — a running forward command is already normalized
/// and must not be touched again. The sidestep negate (SideStepLeft →
/// SideStepRight, ×-1) happens BEFORE the ×1.248 anim-rate scale, so the
/// net multiplier for SideStepLeft is -1.248×speed, not -1×(1.248×speed)
/// applied out of order (same result here, but the ORDER matters once
/// apply_run_to_command's sign-dependent clamp is layered on top).
/// </para>
///
/// <para>
/// <b>Creature guard (retail <c>weenie.IsCreature()</c>) is UNWIRED.</b>
/// <see cref="IWeenieObject"/> does not currently expose an
/// <c>IsCreature</c> query, so this port always proceeds (equivalent to
/// "no weenie" or "is a creature" — true for the player, which is the
/// only caller as of D6.1). Flagged for D6.2 / a future IWeenieObject
/// extension; do not fabricate a guess at the query here.
/// </para>
/// </summary>
/// <param name="motion">Raw motion command, normalized in place.</param>
/// <param name="speed">Raw motion speed, normalized in place.</param>
/// <param name="holdKey">
/// The hold key for THIS channel (forward/sidestep/turn each carry their
/// own hold key in the retail raw state). Pass <see cref="HoldKey.Invalid"/>
/// to fall back to <see cref="CurrentHoldKey"/>.
/// </param>
public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey)
{
// Creature guard unwired — IWeenieObject has no IsCreature(); always proceed.
switch (motion)
{
case MotionCommand.RunForward:
// Already normalized; no scale, no hold-key path.
return;
case MotionCommand.WalkBackward:
motion = MotionCommand.WalkForward;
speed *= -BackwardsFactor;
break;
case MotionCommand.TurnLeft:
motion = MotionCommand.TurnRight;
speed *= -1f;
break;
case MotionCommand.SideStepLeft:
motion = MotionCommand.SideStepRight;
speed *= -1f;
break;
}
if (motion == MotionCommand.SideStepRight)
speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed);
if (holdKey == HoldKey.Invalid)
holdKey = CurrentHoldKey;
if (holdKey == HoldKey.Run)
apply_run_to_command(ref motion, ref speed);
}
// ── FUN_00527be0 — apply_run_to_command ───────────────────────────────────
/// <summary>
/// Apply the Run hold-key promotion/scale to an already-normalized
/// (positive-form) motion command.
///
/// <para>
/// Decompiled logic (FUN_00527be0, <c>docs/research/named-retail/
/// acclient_2013_pseudo_c.txt:305062-305123</c>):
/// <code>
/// speedMod = 1.0
/// if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate
/// switch cmd:
/// WalkForward:
/// if speed > 0: cmd = RunForward // promote ONLY when moving forward
/// speed *= speedMod // UNCONDITIONAL — applies to backward too
/// TurnRight:
/// speed *= RunTurnFactor // ×1.5, no run-rate, no clamp
/// SideStepRight:
/// speed *= speedMod
/// if abs(speed) > MaxSidestepAnimRate: speed = speed > 0 ? Max : -Max
/// </code>
/// </para>
///
/// <para>
/// GOTCHA: the WalkForward <c>speed *= speedMod</c> is unconditional —
/// it applies even when speed is negative (backward), even though the
/// command is NOT promoted to RunForward in that case (the promotion is
/// sign-gated, the scale is not).
/// </para>
/// </summary>
public void apply_run_to_command(ref uint motion, ref float speed)
{
float speedMod = 1.0f;
if (WeenieObj is not null)
speedMod = WeenieObj.InqRunRate(out float rate) ? rate : MyRunRate;
switch (motion)
{
case MotionCommand.WalkForward:
if (speed > 0f)
motion = MotionCommand.RunForward;
speed *= speedMod;
break;
case MotionCommand.TurnRight:
speed *= RunTurnFactor;
break;
case MotionCommand.SideStepRight:
speed *= speedMod;
if (MathF.Abs(speed) > MaxSidestepAnimRate)
speed = speed > 0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate;
break;
}
}
// ── 0x005287e0 — apply_raw_movement ───────────────────────────────────────
/// <summary>
/// Retail <c>CMotionInterp::apply_raw_movement</c> orchestrator
/// (<c>0x005287e0</c>, <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834</c>).
/// Copies the raw motion state into the interpreted state, then normalizes
/// each of the three channels (forward / sidestep / turn) IN PLACE via
/// <see cref="adjust_motion"/> using that channel's own hold key.
///
/// <para>
/// After this call, <see cref="get_state_velocity"/> yields correct velocity
/// for ALL directions — backward (WalkBackward→WalkForward, ×-0.65) and
/// strafe-left (SideStepLeft→SideStepRight, ×-1×1.248) are normalized here,
/// which is the D6 fix that retires the hand-mirrored controller formulas
/// (register TS-22). Callers must pass RAW speeds (forward_speed = 1.0), NOT
/// pre-run-scaled speeds — <see cref="apply_run_to_command"/> applies the run
/// rate, so a pre-scaled input would double-scale.
/// </para>
/// </summary>
public void apply_raw_movement(RawMotionState raw)
{
CurrentHoldKey = raw.CurrentHoldKey;
// Copy raw -> interpreted. Retail copies 7 fields; our
// InterpretedMotionState omits current_style (velocity doesn't use it).
InterpretedState.ForwardCommand = raw.ForwardCommand;
InterpretedState.ForwardSpeed = raw.ForwardSpeed;
InterpretedState.SideStepCommand = raw.SidestepCommand;
InterpretedState.SideStepSpeed = raw.SidestepSpeed;
InterpretedState.TurnCommand = raw.TurnCommand;
InterpretedState.TurnSpeed = raw.TurnSpeed;
// Normalize each channel with its OWN per-channel hold key.
uint fCmd = InterpretedState.ForwardCommand; float fSpd = InterpretedState.ForwardSpeed;
adjust_motion(ref fCmd, ref fSpd, raw.ForwardHoldKey);
InterpretedState.ForwardCommand = fCmd; InterpretedState.ForwardSpeed = fSpd;
uint sCmd = InterpretedState.SideStepCommand; float sSpd = InterpretedState.SideStepSpeed;
adjust_motion(ref sCmd, ref sSpd, raw.SidestepHoldKey);
InterpretedState.SideStepCommand = sCmd; InterpretedState.SideStepSpeed = sSpd;
uint tCmd = InterpretedState.TurnCommand; float tSpd = InterpretedState.TurnSpeed;
adjust_motion(ref tCmd, ref tSpd, raw.TurnHoldKey);
InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd;
}
// ── FUN_00529210 — apply_current_movement ─────────────────────────────────
/// <summary>