acdream/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

217 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;
// ─────────────────────────────────────────────────────────────────────────────
// 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 normalization (the DAT MotionData supplies production omega) ────
[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);
}
// ── attack / non-locomotion forward command (#170) ───────────────────────
[Theory]
[InlineData(0x10000062u)] // AttackHigh1
[InlineData(0x10000063u)] // AttackMed1
[InlineData(0x10000064u)] // AttackLow1
[InlineData(0x10000186u)] // AttackHigh4 (shifted late block)
public void AttackForwardCommand_ZeroVelocity(uint attackCommand)
{
// #170: an ATTACK forward command (action-class 0x1000006x/0x100001xx)
// is neither WalkForward (0x45000005) nor RunForward (0x44000007), so
// retail's get_state_velocity (0x00527d50) hits its `else → 0` branch —
// the creature plants its feet instead of coasting at the last run
// velocity. Cross-checked against holtburger grounded_local_velocity
// (`_ => Vector3::zero()`) and the retail decomp. This is the invariant
// the #170 glide fix relies on: GameWindow's remote dead-reckon now
// refreshes the body velocity from get_state_velocity each tick, so a
// creature that switches from a run-chase (ForwardCommand=RunForward)
// to an attack (ForwardCommand=0x1000006x) resolves to zero velocity
// and stops gliding.
var interp = MakeInterp(RunRate);
interp.InterpretedState.ForwardCommand = attackCommand;
interp.InterpretedState.ForwardSpeed = 0.97f;
var v = interp.get_state_velocity();
Assert.Equal(0f, v.X, 5);
Assert.Equal(0f, v.Y, 5);
Assert.Equal(0f, v.Z, 5);
}
}