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>
299 lines
10 KiB
C#
299 lines
10 KiB
C#
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);
|
||
}
|
||
}
|