acdream/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.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

187 lines
8.5 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;
// ─────────────────────────────────────────────────────────────────────────────
// MotionVelocityPipelineTests — Phase D6.2: the full raw→interpreted→velocity
// pipeline (apply_raw_movement 0x005287e0 → get_state_velocity 0x00527d50).
//
// These pin the retail-faithful LOCAL velocity for every direction — the exact
// behavior the D6.2 controller integration now routes through, replacing the
// hand-mirrored backward/strafe formulas (register TS-22). Golden values are
// derived from the pseudocode doc
// (docs/research/2026-07-01-d6-motion-interp-pseudocode.md) and the retail
// constants on MotionInterpreter.
//
// Notable retail-faithful change vs the old hand-mirror: strafe is now
// 1.25 × (0.5·WalkAnimSpeed/SidestepAnimSpeed) × runRate = ~1.56 × runRate,
// clamped via SideStepSpeed ≤ 3.0 (so |v.X| ≤ 3.75). The old code used
// 1.25 × runRate with no sidestep-scale and no clamp.
// ─────────────────────────────────────────────────────────────────────────────
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 MotionVelocityPipelineTests
{
private const float RunRate = 2.75f; // ≈ the +Acdream test char's run rate
private const float WalkAnim = MotionInterpreter.WalkAnimSpeed; // 3.11999989
private const float RunAnim = MotionInterpreter.RunAnimSpeed; // 4.0
private const float SideAnim = MotionInterpreter.SidestepAnimSpeed; // 1.25
private const float BackFac = MotionInterpreter.BackwardsFactor; // 0.649999976
private const float MaxSide = MotionInterpreter.MaxSidestepAnimRate;// 3.0
// adjust_motion sidestep scale: 0.5 * (WalkAnim / SideAnim) ≈ 1.24799995
private const float SideScale = MotionInterpreter.SidestepFactor * (WalkAnim / SideAnim);
private static MotionInterpreter MakeInterp(float runRate)
=> new() { WeenieObj = new FakeRunRateWeenie { RunRate = runRate } };
private static RawMotionState Raw(
uint forward = MotionCommand.Ready, uint sidestep = 0u, uint turn = 0u,
HoldKey hold = HoldKey.None)
=> new()
{
CurrentHoldKey = hold,
ForwardCommand = forward,
ForwardHoldKey = forward != MotionCommand.Ready ? hold : HoldKey.Invalid,
ForwardSpeed = 1.0f,
SidestepCommand = sidestep,
SidestepHoldKey = sidestep != 0u ? hold : HoldKey.Invalid,
SidestepSpeed = 1.0f,
TurnCommand = turn,
TurnHoldKey = turn != 0u ? hold : HoldKey.Invalid,
TurnSpeed = 1.0f,
};
// ── forward ──────────────────────────────────────────────────────────────
[Fact]
public void RunForward_VelocityIsRunAnimSpeedTimesRunRate()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.Run));
// WalkForward + Run → RunForward @ runRate; v.Y = RunAnim * runRate (== maxSpeed, no clamp).
var v = interp.get_state_velocity();
Assert.Equal(RunAnim * RunRate, v.Y, 3);
Assert.Equal(0f, v.X, 5);
}
[Fact]
public void WalkForward_NoRun_VelocityIsWalkAnimSpeed()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.None));
var v = interp.get_state_velocity();
Assert.Equal(WalkAnim * 1.0f, v.Y, 3); // no run scaling; WalkForward @ 1.0
}
// ── backward (the TS-22 fix: no longer zero) ─────────────────────────────
[Fact]
public void RunBackward_VelocityIsNegativeWalkTimesBackwardFactorTimesRunRate()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(forward: MotionCommand.WalkBackward, hold: HoldKey.Run));
// WalkBackward → WalkForward, speed *= -0.65; Run → *= runRate (unconditional,
// promotion sign-gated so it stays WalkForward). v.Y = WalkAnim * (-0.65 * runRate).
var v = interp.get_state_velocity();
Assert.Equal(-(WalkAnim * BackFac * RunRate), v.Y, 3);
Assert.True(v.Y < 0f, "backward velocity must be negative");
// Matches the OLD hand-mirror (WalkAnim * 0.65 * runMul) — backward is unchanged.
}
// ── strafe (retail-faithful magnitude + ±3.0 clamp) ──────────────────────
[Fact]
public void RunStrafeRight_ClampsSideStepSpeedToThree_VelocityIs3p75()
{
var interp = MakeInterp(RunRate); // 1.248*2.75 = 3.432 > 3.0 → clamps
interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run));
var v = interp.get_state_velocity();
Assert.Equal(SideAnim * MaxSide, v.X, 3); // 1.25 * 3.0 = 3.75
Assert.Equal(3.75f, v.X, 3);
Assert.Equal(0f, v.Y, 5);
}
[Fact]
public void RunStrafeRight_BelowClamp_VelocityIsScaledSidestep()
{
const float lowRun = 2.0f; // 1.248*2.0 = 2.496 < 3.0 → no clamp
var interp = MakeInterp(lowRun);
interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run));
var v = interp.get_state_velocity();
// v.X = SideAnim * (SideScale * lowRun) = 1.56 * lowRun ≈ 3.12
Assert.Equal(SideAnim * SideScale * lowRun, v.X, 3);
}
[Fact]
public void RunStrafeLeft_NegatedAndClamped_VelocityIsNeg3p75()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepLeft, hold: HoldKey.Run));
// SideStepLeft → SideStepRight, negated → -1.248; *runRate = -3.432; clamp → -3.0.
var v = interp.get_state_velocity();
Assert.Equal(-(SideAnim * MaxSide), v.X, 3); // -3.75
Assert.True(v.X < 0f, "strafe-left velocity must be negative");
}
// ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ───────────────
[Fact]
public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.Run));
Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
Assert.Equal(MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // +1.5
}
[Fact]
public void RunTurnLeft_RemapsToTurnRightWithNegativeRunTurnFactor()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(turn: MotionCommand.TurnLeft, hold: HoldKey.Run));
// TurnLeft → TurnRight, speed *= -1; Run → *= 1.5 → -1.5.
Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
Assert.Equal(-MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // -1.5
}
[Fact]
public void WalkTurn_NoRun_TurnSpeedIsUnity()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.None));
Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand);
Assert.Equal(1.0f, interp.InterpretedState.TurnSpeed, 5); // no run factor
}
// ── idle ─────────────────────────────────────────────────────────────────
[Fact]
public void Idle_ReadyState_ZeroVelocity()
{
var interp = MakeInterp(RunRate);
interp.apply_raw_movement(Raw()); // Ready, no sidestep/turn
var v = interp.get_state_velocity();
Assert.Equal(0f, v.X, 5);
Assert.Equal(0f, v.Y, 5);
}
}