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:
parent
e0d2492cbb
commit
addc8e97a8
13 changed files with 3779 additions and 0 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue