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

@ -0,0 +1,299 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// MotionNormalizationTests — Phase D6.1: adjust_motion + apply_run_to_command.
//
// Source addresses tested (docs/research/2026-07-01-d6-motion-interp-pseudocode.md):
// FUN_00528010 (0x00528010) CMotionInterp::adjust_motion
// FUN_00527be0 (0x00527be0) CMotionInterp::apply_run_to_command
//
// Golden constants (retail, verified against ACE MotionInterp.cs byte-for-byte):
// BackwardsFactor = 0.649999976 (0x007c8910)
// WalkAnimSpeed = 3.11999989 (0x007c891c, retail-exact — NOT 3.12f)
// SidestepAnimSpeed = 1.25
// SidestepFactor = 0.5
// RunTurnFactor = 1.5
// MaxSidestepAnimRate = 3.0
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Fake WeenieObject for injecting a known run rate into apply_run_to_command /
/// adjust_motion without a real weenie.
/// </summary>
file sealed class FakeRunRateWeenie : IWeenieObject
{
public float RunRate;
public bool InqRunRateResult = true;
public bool InqJumpVelocity(float extent, out float vz)
{
vz = 0f;
return false;
}
public bool InqRunRate(out float rate)
{
rate = RunRate;
return InqRunRateResult;
}
public bool CanJump(float extent) => true;
}
public sealed class MotionNormalizationTests
{
// Retail-exact sidestep scale: SidestepFactor(0.5) * (3.11999989 / 1.25) ≈ 1.24799995...
private const float SidestepScale = 0.5f * (3.11999989f / MotionInterpreter.SidestepAnimSpeed);
private static MotionInterpreter MakeInterp(IWeenieObject? weenie = null)
{
return new MotionInterpreter { WeenieObj = weenie };
}
// ── adjust_motion: per-command remap + scale ─────────────────────────────
[Fact]
public void AdjustMotion_WalkBackward_RemapsToWalkForward_NegatesAndScalesSpeed()
{
// 0x00528010: WalkBackwards -> cmd = WalkForward; speed *= -BackwardsFactor
var interp = MakeInterp();
uint motion = MotionCommand.WalkBackward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.WalkForward, motion);
Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, 5);
Assert.Equal(-0.649999976f, speed, 5);
}
[Fact]
public void AdjustMotion_TurnLeft_RemapsToTurnRight_NegatesSpeed()
{
var interp = MakeInterp();
uint motion = MotionCommand.TurnLeft;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.TurnRight, motion);
Assert.Equal(-1.0f, speed, 5);
}
[Fact]
public void AdjustMotion_SideStepLeft_RemapsToSideStepRight_NegatesBeforeScale()
{
// Negate happens BEFORE the ×1.248 sidestep scale, so the net factor is -1.248.
var interp = MakeInterp();
uint motion = MotionCommand.SideStepLeft;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.SideStepRight, motion);
Assert.Equal(-SidestepScale, speed, 5);
Assert.Equal(-1.24799995f, speed, 4);
}
[Fact]
public void AdjustMotion_SideStepRight_ScalesSpeed_NoNegate()
{
var interp = MakeInterp();
uint motion = MotionCommand.SideStepRight;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.SideStepRight, motion);
Assert.Equal(SidestepScale, speed, 5);
Assert.Equal(1.24799995f, speed, 4);
}
[Fact]
public void AdjustMotion_RunForward_EarlyReturns_NoScaleNoHoldKeyPath()
{
// GOTCHA: RunForward returns immediately -- no scale AND no holdkey promotion,
// even when holdKey == Run (there's nothing left to promote it to, but the
// early-return also means speed is untouched).
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.RunForward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Run);
Assert.Equal(MotionCommand.RunForward, motion);
Assert.Equal(1.0f, speed, 5);
}
[Fact]
public void AdjustMotion_NonSidestepNonTurnNonForward_Unchanged_ExceptHoldKeyPath()
{
// Ready is not one of the switch cases and not SideStepRight after remap,
// so command/speed pass through unchanged when holdKey doesn't promote it.
var interp = MakeInterp();
uint motion = MotionCommand.Ready;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.Ready, motion);
Assert.Equal(1.0f, speed, 5);
}
// ── adjust_motion: holdKey inheritance + Run promotion ───────────────────
[Fact]
public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun()
{
var interp = MakeInterp();
interp.CurrentHoldKey = HoldKey.Run;
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid);
// apply_run_to_command: WalkForward + speed>0 -> RunForward; speed *= speedMod (1.0, no weenie).
Assert.Equal(MotionCommand.RunForward, motion);
Assert.Equal(1.0f, speed, 5);
}
[Fact]
public void AdjustMotion_HoldKeyNone_DoesNotPromote()
{
var interp = MakeInterp();
interp.CurrentHoldKey = HoldKey.Run;
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.None);
Assert.Equal(MotionCommand.WalkForward, motion);
Assert.Equal(1.0f, speed, 5);
}
[Fact]
public void AdjustMotion_HoldKeyRunPassedDirectly_PromotesWalkForward()
{
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.75f });
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
interp.adjust_motion(ref motion, ref speed, HoldKey.Run);
Assert.Equal(MotionCommand.RunForward, motion);
Assert.Equal(2.75f, speed, 5);
}
// ── apply_run_to_command ──────────────────────────────────────────────────
[Fact]
public void ApplyRunToCommand_WalkForward_PositiveSpeed_PromotesToRunForward_ScalesByRunRate()
{
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.RunForward, motion);
Assert.Equal(2.94f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_WalkForward_NegativeSpeed_StaysWalkForward_ScalesUnconditionally()
{
// GOTCHA: speed *= speedMod is UNCONDITIONAL -- backward (negative speed)
// still gets run-scaled even though it is NOT promoted to RunForward.
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.WalkForward;
float speed = -0.649999976f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.WalkForward, motion);
Assert.Equal(-0.649999976f * 2.94f, speed, 4);
}
[Fact]
public void ApplyRunToCommand_TurnRight_ScalesByRunTurnFactor_NoRunRateNoClamp()
{
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.TurnRight;
float speed = 1.0f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.TurnRight, motion);
Assert.Equal(1.5f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_SideStepRight_BelowClamp_ScalesByRunRate()
{
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.0f });
uint motion = MotionCommand.SideStepRight;
float speed = 1.0f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.SideStepRight, motion);
Assert.Equal(2.0f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_SideStepRight_AbovePositiveClamp_ClampsToPositiveMax()
{
// runRate 2.94 * speed 1.248 (post adjust_motion sidestep scale) ≈ 3.669 > 3.0
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.SideStepRight;
float speed = 1.24799995f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.SideStepRight, motion);
Assert.Equal(3.0f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_SideStepRight_AboveNegativeClamp_ClampsToNegativeMax()
{
var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f });
uint motion = MotionCommand.SideStepRight;
float speed = -1.24799995f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.SideStepRight, motion);
Assert.Equal(-3.0f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_UsesMyRunRate_WhenInqRunRateFails()
{
var weenie = new FakeRunRateWeenie { RunRate = 999f, InqRunRateResult = false };
var interp = MakeInterp(weenie);
interp.MyRunRate = 1.5f;
uint motion = MotionCommand.WalkForward;
float speed = 1.0f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(MotionCommand.RunForward, motion);
Assert.Equal(1.5f, speed, 5);
}
[Fact]
public void ApplyRunToCommand_NoWeenie_SpeedModDefaultsToOne()
{
var interp = MakeInterp(weenie: null);
uint motion = MotionCommand.SideStepRight;
float speed = 1.0f;
interp.apply_run_to_command(ref motion, ref speed);
Assert.Equal(1.0f, speed, 5);
}
}