acdream/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs
Erik fb7beb706b feat(R3-W6): LOCAL PLAYER UNIFICATION — edge-driven retail input; UpdatePlayerAnimation + the synthesis layer DELETED (closes J15)
The local player now drives the SAME pipeline remotes use. The
controller's per-frame RawMotionState rebuild (level-triggered D6.2) is
replaced by retail's CommandInterpreter altitude: key EDGES fire
DoMotion/StopMotion (0x00528d20/0x00528530) and the Shift edge fires
set_hold_run (0x00528b70, caller-0x006b33ca shape) — the interpreter's
own RawState mutates via ApplyMotion/RemoveMotion and every dispatch
routes the funnel + DefaultSink into the sequencer. GameWindow's
UpdatePlayerAnimation (169 lines) is DELETED with both
LocalAnimationCommand/LocalAnimationSpeed MovementResult fields and all
three synthesis sites (K-fix5 pacing, Walk→Run cycle selection, the
auto-walk overrides): run pacing now comes from apply_run_to_command's
my_run_rate promotion — the same source remotes use; airborne-Falling
falls out of contact_allows_move (the funnel's second dispatch).
Auto-walk's #69 turn-phase legs re-expressed as DoMotion(Turn*) edges;
teleport idle = StopCompletely (retail's full stop) + an edge-tracker
reset so held keys walk straight out of a portal.

Map discovery R1 fixed: ChargeJump() now fires at charge START (the
0x0056afac input-boundary site) — StandingLongJump had NEVER armed in
production despite the W3 port.

TWO root-cause fixes the cutover surfaced (each would have been
invisible under the old synthesis layer):
- CurrentHoldKey was a shadow FIELD synced only on the raw dispatch
  branch; set_hold_run writes RawState.CurrentHoldKey, so an edge-driven
  Shift toggle followed by DoMotion read a stale None and lost the run
  promotion. Now an alias of RawState.CurrentHoldKey (retail's
  adjust_motion reads raw_state.current_holdkey directly).
- The controller's grounded velocity + jump-launch writes used
  set_local_velocity's default autonomous:false, silently clearing
  LastMoveWasAutonomous and flipping the A3 dual dispatch to the
  interpreted branch for the local player. Both marked autonomous.

EXPECTED-DIFFS (map §6, for the R3 visual pass): #45's 1.248x sidestep
factor + ACDREAM_ANIM_SPEED_SCALE died with UpdatePlayerAnimation —
local sidestep pacing now matches how remotes have always played;
auto-walk-at-run plays walk-pace legs until R4; ApplyServerRunRate's
apply_current_movement goes live (Branch-2 fast re-speed absorbs the
echo — retail's own mid-run speed-change mechanism).

Wire: outbound values stay input-derived (the L.2b-verified byte
stream untouched; RawState-sourcing needs the map §2b cdb TODO and R7
owns outbound). MotionStateChanged = the old comparison minus the dead
localAnimCmd leg, OR any edge fired.

Full suite green: 3,731 passed. Live smoke: in-world, user-driven
input through the new path (walk/turn/strafe), 12k entities, zero
exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 09:19:56 +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.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
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.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState
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);
}
}