acdream/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs
Erik d2ccc80e59 fix(#170): refresh remote body velocity from get_state_velocity (kill the attack glide)
Root cause (confirmed by a live ACDREAM_DUMP_MOTION capture of Mite Scamp
0x80000244 + the retail decomp): a chasing+attacking creature's attacks arrive
as the ForwardCommand of frequent mt-0 InterpretedMotionState UMs (66 attack UMs
0x62/63/64 vs 2 mt-6 chase MoveTos in the capture). Retail's get_state_velocity
(0x00527d50) computes the body's translation velocity from the current forward
command: WalkForward→3.12×spd, RunForward→4.0×spd, and 0 for everything else
(an attack) — so the creature plants its feet. acdream ALREADY has a faithful
get_state_velocity (returns 0 for a non-locomotion command; cross-checked vs
holtburger grounded_local_velocity's `_ => zero`), but it was never WIRED to the
remote body for entities with an animation sink: apply_current_movement's sink
path (all remotes have a DefaultSink) dispatches the animation and early-returns
BEFORE the set_local_velocity(get_state_velocity()) write, which lives only in
the no-sink fallback (MotionInterpreter ~1702). So a remote NPC's body.Velocity
was never recomputed from its motion state and kept the STALE run velocity from
the last chase — the body dead-reckoned forward at ~4 m/s while playing an
idle+attack pose ("glides after me").

Fix: after apply_current_movement in the grounded remote-NPC dead-reckon path
(GameWindow ~9992, restricted to remotes by serverGuid != player and to grounded
by OnWalkable), refresh rm.Body via set_local_velocity(get_state_velocity()) —
the exact write retail's apply_current_movement performs, reusing the verbatim
ported get_state_velocity. An attack forward command now resolves to 0, so the
creature stops and swings in place; RunForward still yields the run velocity.
Narrowest safe seam: the local player (which also has a sink) is excluded by the
loop's player guard, so its PlayerMovementController velocity path is untouched.

The earlier suspicion (#159 combat-command numbering) was a red herring — the
scamp's 0x62/63/64 were always in the correct block and the planner is unwired;
the wire even carries full attack variety, so "uniform" was the visual artifact
of rapid attacks over a gliding idle base, not a classification bug.

Tests: MotionVelocityPipelineTests.AttackForwardCommand_ZeroVelocity (4 cases)
pins get_state_velocity → 0 for the attack forward commands the fix depends on.
Core suite 2503 green. Visual gate pending (retail side-by-side: creature should
plant + step, not glide).

Ref: docs/research/named-retail get_state_velocity 0x00527d50; ISSUES #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:56:40 +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 (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);
}
// ── 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);
}
}