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,295 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>BeginNextNode</c>'s sticky handoff (<c>00529cb0</c>, raw
/// 307123-307171, decomp §4b) and <c>CancelMoveTo</c>
/// (<c>00529930</c>, raw 306886-306940, decomp §7c) including the
/// reentrancy invariant (r4-port-plan.md §4: CancelMoveTo →
/// CleanUpAndCallWeenie → StopCompletely → InterruptCurrentMovement →
/// CancelMoveTo no-ops on Invalid).
/// </summary>
public sealed class MoveToManagerStickyAndCancelTests
{
// ── Sticky handoff (§4b) — read BEFORE CleanUp, then StickTo ───────────
[Fact]
public void StickyArrival_ReadsRadiusHeightTlidBeforeCleanUp_ThenCallsStickTo()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p);
Assert.True(h.Manager.Params.Sticky); // preserved by MoveToObject (§3b)
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity); // inside default dto -> arrives instantly
h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target));
Assert.Single(h.StickToCalls);
Assert.Equal((0x50005555u, 1.5f, 2.5f), h.StickToCalls[0]);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // CleanUp ran
}
[Fact]
public void NonStickyArrival_NoStickToCall_JustCleanUpAndStop()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = false };
h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p);
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target));
Assert.Empty(h.StickToCalls);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void MoveToPosition_NeverSticks_EvenIfRequested()
{
// §3c: MoveToPosition force-clears the sticky bit — so even an
// arrival that WOULD have stuck (had it been an object move) just
// completes plainly.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = true, DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); // already in range -> instant complete
Assert.Empty(h.StickToCalls);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void StickyHandoff_UsesSoughtRadiusHeight_NotOwnRadiusHeight()
{
var h = new MoveToManagerHarness { OwnRadius = 99f, OwnHeight = 99f };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToObject(0x50006666u, 0x50006666u, radius: 3f, height: 4f, p);
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50006666u, TargetStatus.Ok, target, target));
Assert.Equal((0x50006666u, 3f, 4f), h.StickToCalls[0]); // the TARGET's radius/height, not the mover's own.
}
// ── CancelMoveTo (§7c) — drain + CleanUp + Stop; reentrancy ────────────
[Fact]
public void CancelMoveTo_OnInvalidState_IsANoOp()
{
var h = new MoveToManagerHarness();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(0, h.StopCompletelyCalls); // no-op: never even called StopCompletely
Assert.Equal(0, h.ClearTargetCalls);
}
[Fact]
public void CancelMoveTo_DrainsPendingActions()
{
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.NotEmpty(h.Manager.PendingActions);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Empty(h.Manager.PendingActions);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void CancelMoveTo_ErrorArgument_NeverReadInBody_SameBehaviorForAnyCode()
{
// §7c: the WeenieError arg is NEVER read — every code produces
// identical observable behavior (drain + CleanUp + Stop).
var h1 = new MoveToManagerHarness();
h1.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h1.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h1.Manager.CancelMoveTo(WeenieError.YouChargedTooFar);
var h2 = new MoveToManagerHarness();
h2.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h2.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h2.Manager.CancelMoveTo(WeenieError.NoObject);
Assert.Equal(h1.Manager.MovementTypeState, h2.Manager.MovementTypeState);
Assert.Equal(h1.StopCompletelyCalls, h2.StopCompletelyCalls);
}
[Fact]
public void CancelMoveTo_ClearsTarget_ForObjectMoves()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToObject(0x50007777u, 0x50007777u, 1f, 2f, new MovementParameters());
Assert.Single(h.SetTargetCalls);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(1, h.ClearTargetCalls);
}
[Fact]
public void CancelMoveTo_DoesNotClearTarget_ForPositionMoves()
{
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());
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(0, h.ClearTargetCalls); // TopLevelObjectId is 0 for a position move -> the clear_target gate never fires.
}
[Fact]
public void Reentrancy_StopCompletelyCallback_ReenteringCancelMoveTo_NoOps()
{
// r4-port-plan.md §4 reentrancy invariant: the StopCompletely tail
// of CancelMoveTo/CleanUpAndCallWeenie can re-enter
// InterruptCurrentMovement -> CancelMoveTo (this is exactly what
// happens in production: MotionInterpreter.StopCompletely()
// invokes InterruptCurrentMovement, which V5 binds to
// entity.MoveTo.CancelMoveTo). This must no-op because CleanUp
// already reset movement_type to Invalid BEFORE StopCompletely
// runs. Wire the reentrant callback DIRECTLY (bypassing the shared
// harness, which doesn't expose this seam post-construction) to
// prove the invariant against the real CancelMoveTo/CleanUp
// ordering.
var interp = new MotionInterpreter();
var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active };
interp.PhysicsObj = body;
Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
int stopCompletelyCalls = 0;
int reentrantCancelCalls = 0;
MoveToManager? mgr = null;
mgr = new MoveToManager(
interp,
stopCompletely: () =>
{
stopCompletely_body();
},
getPosition: () => worldPosition,
getHeading: () => 0f,
setHeading: (h, send) => { },
getOwnRadius: () => 0.5f,
getOwnHeight: () => 2f,
contact: () => true,
isInterpolating: () => false,
getVelocity: () => Vector3.Zero,
getSelfId: () => 0x50000001u,
setTarget: (a, b, c, d) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: q => { });
void stopCompletely_body()
{
stopCompletelyCalls++;
// Re-enter: exactly the retail chain
// interp.StopCompletely() -> InterruptCurrentMovement?.Invoke()
// -> entity.MoveTo.CancelMoveTo(ActionCancelled) (V5 binding).
reentrantCancelCalls++;
mgr!.CancelMoveTo(WeenieError.ActionCancelled);
}
mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
int stopCallsAfterArm = stopCompletelyCalls; // MoveToPosition's own unconditional stop (§3c) — 1 call, no reentrancy (MovementTypeState was already Invalid before this call started, so its OWN reentrant CancelMoveTo-from-StopCompletely no-ops too).
mgr.CancelMoveTo(WeenieError.ActionCancelled);
// CancelMoveTo's own StopCompletely fires exactly once more (its
// reentrant CancelMoveTo call finds MovementTypeState already
// Invalid — CleanUp ran first — so it no-ops and does NOT trigger a
// THIRD StopCompletely call).
Assert.Equal(stopCallsAfterArm + 1, stopCompletelyCalls);
Assert.Equal(2, reentrantCancelCalls); // one from MoveToPosition's own stop, one from CancelMoveTo's stop
Assert.Equal(MovementType.Invalid, mgr.MovementTypeState);
}
[Fact]
public void CleanUpAndCallWeenie_FiresMoveToComplete_WithGivenError()
{
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());
h.Manager.CleanUpAndCallWeenie(WeenieError.None);
Assert.Single(h.MoveToCompleteCalls);
Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void CleanUpAndCallWeenie_Ordering_MovementTypeAlreadyInvalid_WhenStopCompletelyFires()
{
// §7e: CleanUpAndCallWeenie = CleanUp() THEN StopCompletely() —
// reentrancy-safe ordering (the same ordering CancelMoveTo uses).
// Directly observe MovementTypeState AT THE MOMENT StopCompletely
// is invoked by wiring a probe into the seam.
var interp = new MotionInterpreter();
var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active };
interp.PhysicsObj = body;
Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
MovementType? stateDuringStopCompletely = null;
MoveToManager? mgr = null;
mgr = new MoveToManager(
interp,
stopCompletely: () => stateDuringStopCompletely = mgr!.MovementTypeState,
getPosition: () => worldPosition,
getHeading: () => 0f,
setHeading: (h, send) => { },
getOwnRadius: () => 0.5f,
getOwnHeight: () => 2f,
contact: () => true,
isInterpolating: () => false,
getVelocity: () => Vector3.Zero,
getSelfId: () => 0x50000001u,
setTarget: (a, b, c, d) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: q => { });
mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
mgr.CleanUpAndCallWeenie(WeenieError.None);
Assert.Equal(MovementType.Invalid, stateDuringStopCompletely);
}
[Fact]
public void CleanUp_StopsCurrentAndAuxCommands_ClearsTargetForObjectMoves_ThenReinitializes()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToObject(0x50008888u, 0x50008888u, 1f, 2f, new MovementParameters());
var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50008888u, TargetStatus.Ok, target, target));
Assert.NotEqual(0u, h.Manager.CurrentCommand);
h.Manager.CleanUp();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Equal(1, h.ClearTargetCalls);
Assert.Equal(0u, h.Manager.CurrentCommand);
}
}