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