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,154 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — scripted end-to-end table drive (r4-port-plan.md §3 V2 test
/// list): positions fed per tick -> expected node pops + dispatched motion
/// ids/hold keys, including the run-to-walk demote as the mover closes to
/// within <c>WalkRunThreshhold</c> of the target — the exact R3 visual-pass
/// expected-diff this closes ("auto-walk-at-run walk-pace legs (R4)").
/// </summary>
public sealed class MoveToManagerEndToEndTableDriveTests
{
[Fact]
public void ChaseSequence_TurnFirst_ThenRun_ThenDemoteToWalk_ThenArrive()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f; // facing NORTH; target is due EAST -> a turn-to-face node is required first.
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
// Step 1: node plan = [TurnToHeading(90), MoveToPosition]. Heading
// diff (0->90) is large -> BeginTurnToHeading armed a real turn
// (not the "already there" early-out).
Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions));
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
h.DrainPendingMotions();
// Step 2: the turn completes (heading snaps to 90 via
// HandleTurnToHeading's arrival branch) -> BeginNextNode pops to the
// MoveToPosition node -> BeginMoveForward dispatches. Far from the
// target (100 units, minus threshold 15 well exceeded) -> RunForward.
h.Heading = 91f; // "passed" 90
h.Manager.HandleTurnToHeading();
h.DrainPendingMotions();
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
Assert.Single(h.Manager.PendingActions);
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
// Step 3: close the distance to just inside the walk/run threshold.
// Phase 2 of HandleMoveToPosition doesn't re-run get_command; only
// BeginMoveForward does (dispatched once per node, on arm) — so the
// walk-demote re-evaluation requires a fresh moveto issue. Route it
// through PerformMovement (NOT a direct MoveToPosition call) — this
// is the retail-faithful re-issue shape (§3a: cancel current +
// unstick FIRST, THEN dispatch) and avoids stacking a stale node
// onto the still-populated queue the way a bare second
// MoveToPosition call would (MoveToPosition itself does not drain;
// only PerformMovement's CancelMoveTo call does).
h.WorldPosition = new Position(1u, new Vector3(90f, 0f, 0f), Quaternion.Identity); // 10 units from target
h.Heading = 90f; // already facing it
var pClose = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false };
h.Manager.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity),
Params = pClose,
});
// PerformMovement's CancelMoveTo (§3a) stops the in-flight motion
// FIRST, which itself enqueues a pending_motions node -- so
// BeginTurnToHeading's wait-for-anims gate (§4d) defers the "already
// facing it" early-out to the NEXT drain, not synchronously inside
// this call. Drain twice: once for the cancel's own stop dispatch,
// once more for whatever BeginTurnToHeading/BeginMoveForward issues
// once armed.
h.DrainPendingMotions();
h.Manager.BeginNextNode(); // re-check the head node now that anims have drained
h.DrainPendingMotions();
Assert.Single(h.Manager.PendingActions); // the stale queue was drained by PerformMovement's CancelMoveTo; the "already facing it" turn completed instantly once anims cleared.
// dist=10, dto=0.6 -> dist-dto=9.4 <= 15 -> WALK.
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply);
// Step 4: arrive.
h.WorldPosition = new Position(1u, new Vector3(99.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void FleeSequence_WalksBackward_InsideMinBand_ArrivesWhenFarEnough()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity);
h.Heading = 270f; // facing the threat (target) which is behind at origin -- WalkBackward needs no turn.
// towards_and_away band: dist(3) inside [MinDistance(5)... wait need
// dist < min for the inside-band WalkBackward pick]. Use MinDistance
// 5 with mover at distance 3 from the target (origin) -> inside
// band -> WalkBackward, no turn node queued (§5d asymmetry).
var p = new MovementParameters { MoveTowards = true, MoveAway = true, MinDistance = 5f, DistanceToObject = 8f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, Vector3.Zero, Quaternion.Identity), p);
Assert.True(h.Manager.MovingAway);
Assert.Equal(MotionCommand.WalkBackward, h.Manager.CurrentCommand); // pre-adjust id (get_command's own return)
// adjust_motion normalizes WalkBackward -> WalkForward with a
// negative BackwardsFactor-scaled speed, dispatched as ForwardCommand.
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.True(h.ForwardSpeed < 0f);
h.DrainPendingMotions();
// Flee to distance 6 (>= MinDistance 5) -> arrived.
h.WorldPosition = new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void TurnToObjectSequence_DeferredStart_ThenRetargetIgnored_ThenArrivesOnHeadingPass()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
const uint targetId = 0x5000CCCCu;
h.Manager.TurnToObject(targetId, targetId, new MovementParameters());
Assert.Empty(h.Manager.PendingActions); // deferred (§3d)
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90
h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
Assert.Single(h.Manager.PendingActions);
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
h.DrainPendingMotions();
// Retarget while running: TurnToObject gets no handling (heading frozen).
var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0
h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target2, target2));
var soughtBefore = h.Manager.SoughtPosition;
Assert.Equal(soughtBefore, h.Manager.SoughtPosition); // sanity: unchanged by its own read
// Complete the turn toward the ORIGINAL (frozen) heading (90), not target2's.
h.Heading = 91f;
h.Manager.HandleTurnToHeading();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Equal(90f, h.Heading, 2); // snapped to the frozen heading, unaffected by the retarget.
}
}