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>
295 lines
13 KiB
C#
295 lines
13 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>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);
|
|
}
|
|
}
|