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,157 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>MoveToManager::BeginMoveForward</c> (<c>00529a00</c>, raw
/// 306957-307042) per r4-moveto-decomp.md §4c: dispatched motion id / hold
/// key, the write-back to the STORED params, and the progress-clock seed.
/// Also exercises the run→walk demote inside <c>WalkRunThreshhold</c> (the
/// R3 visual-pass expected-diff this closes).
/// </summary>
public sealed class MoveToManagerBeginMoveForwardTests
{
[Fact]
public void FarFromTarget_CanRunCanWalk_DispatchesRunForward()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f; // already facing target — no aux turn needed
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f };
// Distance far beyond threshold+dto -> Run.
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
// MoveToPosition's node plan queues [TurnToHeading(face)] first since
// heading(0->target)=90 != current heading is not tested here (we
// set Heading=90 already so diff=0, GetCommand still picks motion
// because distance is huge, so a turn node is queued anyway — but
// since diff==0 the queued turn will complete immediately in
// BeginNextNode's synchronous dispatch, landing directly on
// BeginMoveForward).
// ForwardCommand (post-adjust_motion, dispatched to the interp) is
// RunForward; CurrentCommand (the manager's OWN field) stores the
// PRE-adjust command GetCommand chose — get_command's own body only
// ever returns WalkForward/WalkBackward/0 (§5c) — the Run promotion
// happens downstream, inside adjust_motion (_DoMotion §7a), and is
// never written back into CurrentCommand.
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
Assert.Equal(MotionCommand.WalkForward, h.Manager.CurrentCommand);
}
[Fact]
public void WithinWalkRunThreshold_DemotesToWalk()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f };
// dist - dto = 10 - 0.6 = 9.4 <= 15 -> walk.
h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void CanCharge_FastPathWins_RunsEvenWhenClose()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, CanCharge = true };
h.Manager.MoveToPosition(new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void HoldKeyWriteBack_ToStoredParams_NotJustLocalCopy()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, HoldKeyToApply = HoldKey.Invalid };
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void ProgressClockSeeded_PreviousAndOriginalEqualCurrentDistance()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.CurTime = 5.0;
// UseSpheres defaults true on a fresh MovementParameters, and
// MoveToPosition's own params (copied into Params BEFORE
// BeginMoveForward runs) drive GetCurrentDistance's use_spheres
// branch: cylinder distance = center distance - ownRadius(0.5) -
// targetRadius(0, position moves always zero SoughtObjectRadius).
var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(20f, h.Manager.PreviousDistance, 2);
Assert.Equal(20f, h.Manager.OriginalDistance, 2);
Assert.Equal(5.0, h.Manager.PreviousDistanceTime, 3);
Assert.Equal(5.0, h.Manager.OriginalDistanceTime, 3);
}
[Fact]
public void ProgressClockSeeded_UseSpheresDefault_UsesCylinderDistance()
{
var h = new MoveToManagerHarness { OwnRadius = 0.5f };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.CurTime = 5.0;
var p = new MovementParameters { DistanceToObject = 0.6f }; // UseSpheres=true (default)
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
// center distance 20 - ownRadius 0.5 - targetRadius 0 (position
// moves zero SoughtObjectRadius, §3c) = 19.5.
Assert.Equal(19.5f, h.Manager.PreviousDistance, 2);
Assert.Equal(19.5f, h.Manager.OriginalDistance, 2);
}
[Fact]
public void CancelMoveToBit_ClearedOnLocalParams_DoesNotSelfCancel()
{
// If the 0x8000 CancelMoveTo bit were NOT cleared on the local
// params passed into _DoMotion, InterruptCurrentMovement-style
// cancellation logic downstream could tear down THIS moveto before
// it starts. We assert the observable effect: the manager is still
// MovingTo after BeginMoveForward dispatches.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.True(h.Manager.IsMovingTo());
}
[Fact]
public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.True(h.Manager.IsMovingTo());
h.Manager.HasPhysicsObj = false;
h.Manager.BeginMoveForward();
Assert.False(h.Manager.IsMovingTo());
}
}