feat(R4-V2): MoveToManager verbatim - all 33 members + conformance harness (closes M1/M3/M4/M5/M6/M10/M14-core)

The retail server-directed-movement brain (0x00529010-0x0052a987),
Core-only with every App dependency as a ctor/property seam for the
V4/V5 cutovers: node-plan builders for all four movement types
(TurnToObject's desired-heading clobber quirk VERBATIM; TurnToHeading's
immediate BeginNextNode - ACE's one-tick-late gap not copied),
PerformMovement (cancel 0x36 + unstick first), BeginNextNode with the
sticky handoff order (radius/height/tlid read BEFORE CleanUp),
BeginMoveForward (GetCommand walk/run cascade + stored-params
write-back + progress-clock seed), HandleMoveToPosition (chase arrival
dist <= distance_to_object per the adjudicated BN inversion; fail
distance -> 0x3D; progress >= 0.25 units/s over >= 1 s, incremental AND
overall; fail_progress_count write-only - retail has NO give-up
threshold and none was invented), HandleTurnToHeading (20/340 aux
deadband; the Ghidra-confirmed heading_diff mirror), HandleUpdateTarget
0x0052a7d0 (deferred-start: object moves wait for the first Ok
callback; retargets reset the progress clock without requeueing),
UseTime's initialized gate, InitializeLocalVariables per retail (flags
word + context_id zeroed, floats stale, FLT_MAX resets - not ACE's
transpositions).

TDD catch: default(Quaternion) is the ZERO quaternion, not identity -
a fresh manager's heading computations would silently read 90 degrees;
explicit IdentityPosition resets match the decomp's identity-Frame
semantics. Also pinned: retail's explicit double adjust_motion in
_DoMotion/_StopMotion; entry points never drain pending_actions (only
PerformMovement's cancel does) - re-issues must route through
PerformMovement, documented + tested.

101 new conformance tests incl. three end-to-end scripted drives
(chase turn->run->walk-demote->arrive; flee; frozen-heading
TurnToObject through retarget). Full suite: 3,961 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 11:43:50 +02:00
parent e0d2492cbb
commit addc8e97a8
13 changed files with 3779 additions and 0 deletions

View file

@ -0,0 +1,147 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>HandleMoveToPosition</c> Phase 1 aux-turn steering
/// (<c>00529d80</c>, raw 307187-307280) per r4-moveto-decomp.md §6b: the
/// 20°/340° deadband, direction pick (diff ≥ 180 → TurnLeft else TurnRight),
/// no-redundant-reissue, and the "stop aux while animating" branch.
/// </summary>
public sealed class MoveToManagerAuxTurnTests
{
/// <summary>Drives a manager into an active MoveToPosition (heading
/// already settled so BeginMoveForward runs on the first BeginNextNode),
/// then drains the interp's pending_motions queue via a synthetic
/// MotionDone callback — standing in for "the WalkForward/RunForward
/// animation-table dispatch cycle has started/completed" the way a real
/// AnimationSequencer would signal it. Without this, MotionsPending()
/// stays true forever (BeginMoveForward's own _DoMotion dispatch
/// enqueues a node that nothing else in this bare harness ever pops),
/// and HandleMoveToPosition's Phase 1 would perpetually take the
/// "animating, stop aux" branch — never exercising the deadband/turn
/// logic this test file targets.</summary>
private static MoveToManagerHarness ArmMoving(float initialHeading, Vector3 targetXY)
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = initialHeading;
var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, targetXY, Quaternion.Identity), p);
h.DrainPendingMotions();
return h;
}
[Fact]
public void WithinDeadband_NoAuxTurnIssued()
{
// Target due east (heading 90); mover already facing 85 -> diff 5,
// inside [0,20] deadband.
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 85f; // simulate drift after the initial turn-to-face completed
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void JustOutsideDeadband_Positive_IssuesTurnRight()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f; // diff = 90-40 = 50 -> outside [0,20][340,360)
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
}
[Fact]
public void DiffAtOrAbove180_IssuesTurnLeft()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 300f; // diff = 90-300 = -210 -> +360 = 150... need >=180 for TurnLeft; pick 260.
h.Heading = 260f; // diff = 90-260=-170 -> +360=190 (>=180) -> TurnLeft
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnLeft, h.Manager.AuxCommand);
}
[Fact]
public void DeadbandUpperBoundary_340_NoTurn()
{
var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0
h.Heading = 20f; // diff = 0-20=-20 -> +360=340 -> boundary INCLUSIVE (diff >= 340)
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void DeadbandLowerBoundary_20_NoTurn()
{
var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0
h.Heading = -20f % 360f; // normalize below
h.Heading = 340f; // diff = 0-340 = -340 -> +360=20 -> boundary INCLUSIVE (diff <= 20)
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void NoRedundantReissue_SameDirectionTwice_DoesNotRedispatch()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f; // outside deadband -> TurnRight
h.Manager.HandleMoveToPosition();
uint firstAux = h.Manager.AuxCommand;
Assert.Equal(MotionCommand.TurnRight, firstAux);
// Drain the interp's pending_motions queue (the TurnRight dispatch
// just enqueued a node) so Phase 1's "not animating" branch runs
// again on the second tick — otherwise MotionsPending() would stay
// true and Phase 1 would take the "animating, stop aux" branch
// instead of exercising the redundant-reissue guard this test
// targets.
h.DrainPendingMotions();
int stopCallsBefore = h.StopCompletelyCalls; // unrelated counter, just for isolation
// Second tick, still outside deadband, same direction -> _DoMotion
// should NOT be re-issued (turn != AuxCommand test fails since
// AuxCommand already equals TurnRight) — assert AuxCommand is
// unchanged (still TurnRight) as the observable proxy.
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
Assert.Equal(stopCallsBefore, h.StopCompletelyCalls);
}
[Fact]
public void AnimatingMotionsPending_StopsAuxTurn_DoesNotStartNew()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f;
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
// Simulate an animation-table motion still pending by queueing a
// node onto the REAL MotionInterpreter's pending_motions.
h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0);
Assert.True(h.Interp.MotionsPending());
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
}