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>
147 lines
5.7 KiB
C#
147 lines
5.7 KiB
C#
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);
|
||
}
|
||
}
|