acdream/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
Erik 0f099bb652 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>
2026-07-01 08:57:17 +02:00

299 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
}