acdream/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs
Erik f961d70023 feat(physics): port retail complete object frame pipeline
Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates.

Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-20 09:10:31 +02:00

400 lines
15 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R5-V5 — <see cref="MovementManager"/> facade conformance (retail struct
/// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>).
/// The facade is pure relay/ownership — these tests pin the retail call
/// shapes: which child each method touches, the lazy MakeMoveToManager
/// create, the PerformMovement type dispatch, and null-tolerance before the
/// moveto manager exists. Behavior of the children themselves is covered by
/// the MotionInterpreter / MoveToManager suites (UNMODIFIED by R5-V5).
/// </summary>
public sealed class MovementManagerTests
{
/// <summary>Non-creature weenie: silences the MotionInterpreter's
/// HitGround/LeaveGround creature gates (retail IsCreature vtable
/// +0x2c) so a test can isolate the facade's MOVETO relay leg.</summary>
private sealed class NonCreatureWeenie : IWeenieObject
{
public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; }
public bool InqRunRate(out float rate) { rate = 1f; return false; }
public bool CanJump(float extent) => false;
bool IWeenieObject.IsCreature() => false;
}
/// <summary>Facade over the shared MoveToManagerHarness: the harness's
/// REAL MotionInterpreter is the minterp child; the harness's REAL
/// seam-scripted MoveToManager arrives via <see cref="MovementManager.MoveToFactory"/>
/// (the acdream stand-in for retail's physics_obj/weenie_obj
/// backpointers that MakeMoveToManager constructs from).</summary>
private static (MovementManager Mm, MoveToManagerHarness H, int[] FactoryCalls) MakeFacade()
{
var h = new MoveToManagerHarness();
var factoryCalls = new int[1];
var mm = new MovementManager(h.Interp)
{
MoveToFactory = () => { factoryCalls[0]++; return h.Manager; },
};
return (mm, h, factoryCalls);
}
// ── MakeMoveToManager — 0x00524000 ──────────────────────────────────────
[Fact]
public void MakeMoveToManager_CreatesViaFactory_ExactlyOnce()
{
var (mm, h, calls) = MakeFacade();
Assert.Null(mm.MoveTo);
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
// Retail: no-op if already present.
mm.MakeMoveToManager();
Assert.Same(h.Manager, mm.MoveTo);
Assert.Equal(1, calls[0]);
}
[Fact]
public void MakeMoveToManager_WithoutFactory_IsANoOp()
{
var mm = new MovementManager(new MotionInterpreter());
mm.MakeMoveToManager();
Assert.Null(mm.MoveTo);
}
// ── PerformMovement — 0x005240d0 (the type-1..9 two-way dispatch) ───────
[Fact]
public void PerformMovement_InterpTypes_RouteToMinterp_NotMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.InterpretedCommand,
Motion = MotionCommand.WalkForward,
Speed = 1f,
ModifyInterpretedState = true,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(MotionCommand.WalkForward, h.Interp.InterpretedState.ForwardCommand);
// Types 1-5 never touch the moveto side (jump table 0x0052415c).
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void PerformMovement_InterpTypes_ForwardOriginalParametersObject()
{
// CMotionInterp::PerformMovement @ 0x00528E80 forwards
// MovementStruct::params verbatim. This is observable at the player
// input boundary: rebuilding the block from MovementStruct's legacy
// scalar fields loses its speed, hold key, and control flags.
var (mm, h, calls) = MakeFacade();
int interrupts = 0;
h.Interp.InterruptCurrentMovement = () => interrupts++;
var parameters = new MovementParameters
{
Speed = 0.37f,
SetHoldKey = true,
HoldKeyToApply = HoldKey.Run,
CancelMoveTo = false,
ModifyRawState = true,
ModifyInterpretedState = true,
};
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.RawCommand,
Motion = MotionCommand.TurnRight,
// Deliberately contradictory legacy fields: Params wins.
Speed = 9f,
ModifyRawState = false,
ModifyInterpretedState = false,
Params = parameters,
});
Assert.Equal(WeenieError.None, result);
Assert.Equal(0.37f, h.Interp.RawState.TurnSpeed);
Assert.Equal(HoldKey.Run, h.Interp.RawState.CurrentHoldKey);
Assert.Equal(0, interrupts);
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{
var (mm, h, calls) = MakeFacade();
var result = mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
// Retail cases 5-8 (types 6-9): MakeMoveToManager first, delegate,
// and the MoveToManager path's return is NOT propagated (@0052414f
// `return 0`) — the facade reports None regardless.
Assert.Equal(WeenieError.None, result);
Assert.Equal(1, calls[0]);
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
[Fact]
public void PerformMovement_InvalidAndOutOfRangeTypes_Fail0x47()
{
var (mm, _, calls) = MakeFacade();
// Retail head: (type - 1) > 8 → 0x47. Type 0 underflows unsigned →
// always > 8; anything above 9 fails the same check.
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.Invalid }));
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = (MovementType)10 }));
Assert.Equal(0, calls[0]);
}
[Fact]
public void PerformMovement_ActivatesBeforeDispatch_EvenWhenTypeIsInvalid()
{
var (mm, _, _) = MakeFacade();
int activations = 0;
mm.ActivatePhysicsObject = () => activations++;
Assert.Equal(WeenieError.None,
mm.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
}));
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
}));
Assert.Equal(2, activations);
}
[Fact]
public void PerformMovement_MoveToType_WithoutFactory_Fails0x47()
{
// acdream-only guard for the unreachable-in-production ordering
// (a type-6..9 event before the bind sites set MoveToFactory):
// retail would MoveToManager::Create here; without a factory the
// facade reports the same 0x47 the range check uses.
var mm = new MovementManager(new MotionInterpreter());
Assert.Equal(WeenieError.GeneralMovementFailure,
mm.PerformMovement(new MovementStruct { Type = MovementType.TurnToHeading }));
}
// ── UseTime — 0x005242f0 (moveto only; never lazy-creates) ──────────────
[Fact]
public void UseTime_BeforeMoveToExists_IsANoOp_AndDoesNotCreate()
{
var (mm, _, calls) = MakeFacade();
mm.UseTime();
Assert.Equal(0, calls[0]);
Assert.Null(mm.MoveTo);
}
[Fact]
public void UseTime_RelaysToMoveTo()
{
// The MoveToManagerUseTimeGateTests arrival shape, driven through
// the facade: grounded, facing the target, arrived — one UseTime
// completes the move.
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false },
});
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
mm.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
// ── HitGround — 0x00524300 (minterp FIRST, then moveto) ────────────────
[Fact]
public void HitGround_RelaysToMinterp_AndToleratesNullMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity; // CMotionInterp::HitGround gates on state & 0x400
bool minterpHit = false;
h.Interp.RemoveLinkAnimations = () => minterpHit = true;
mm.HitGround(); // MoveTo still null — retail's if-present guard
Assert.True(minterpHit);
}
[Fact]
public void HitGround_RelaysMinterpFirst_ThenMoveTo()
{
var (mm, h, _) = MakeFacade();
h.Body.State |= PhysicsStateFlags.Gravity;
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
// Order pin: the FIRST RemoveLinkAnimations firing belongs to the
// minterp leg (CMotionInterp::HitGround invokes it before any
// dispatch), at which point the moveto leg's BeginNextNode
// re-dispatch has NOT happened yet — the pending queue is still
// drained. Later firings (the moveto dispatch's own TS-40
// detached-strip at the DoInterpretedMotion tail runs AFTER its
// enqueue) must not overwrite the recording — ??= keeps the first.
bool? queueEmptyAtMinterpLeg = null;
h.Interp.RemoveLinkAnimations =
() => queueEmptyAtMinterpLeg ??= !h.Interp.MotionsPending();
mm.HitGround();
Assert.True(queueEmptyAtMinterpLeg); // minterp leg ran first
Assert.True(h.Interp.MotionsPending()); // a re-dispatch landed after it
}
[Fact]
public void HitGround_ReachesMoveTo_WhenMinterpLegIsGated()
{
// Isolate the MOVETO leg: a non-creature weenie makes
// CMotionInterp::HitGround a retail no-op (IsCreature gate), so any
// re-dispatched pending motion can only come from
// MoveToManager::HitGround → BeginNextNode.
var (mm, h, _) = MakeFacade();
h.Interp.WeenieObj = new NonCreatureWeenie();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
h.DrainPendingMotions();
mm.HitGround();
Assert.True(h.Interp.MotionsPending());
}
// ── HandleExitWorld — 0x00524350 (minterp ONLY) ─────────────────────────
[Fact]
public void HandleExitWorld_DrainsMinterp_AndDoesNotTouchMoveTo()
{
var (mm, h, _) = MakeFacade();
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(h.Interp.MotionsPending()); // the arm's dispatch is queued
mm.HandleExitWorld();
Assert.False(h.Interp.MotionsPending());
// Retail HandleExitWorld does NOT touch moveto_manager — the armed
// move survives (its teardown is CancelMoveTo / exit-world at the
// CPhysicsObj layer, not here).
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
// ── CancelMoveTo — 0x005241b0 / IsMovingTo — 0x00524260 ────────────────
[Fact]
public void CancelMoveTo_NullTolerant_AndRelaysToMoveTo()
{
var (mm, h, _) = MakeFacade();
mm.CancelMoveTo(WeenieError.ActionCancelled); // no moveto yet — no throw
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.True(mm.IsMovingTo());
mm.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.False(mm.IsMovingTo());
Assert.True(h.StopCompletelyCalls > 0);
}
[Fact]
public void IsMovingTo_FalseBeforeMoveToExists()
{
var (mm, _, _) = MakeFacade();
Assert.False(mm.IsMovingTo());
}
// ── HandleUpdateTarget — 0x00524790 (→ moveto) ──────────────────────────
[Fact]
public void HandleUpdateTarget_NullTolerant_AndFeedsMoveToDeferredStart()
{
var (mm, h, _) = MakeFacade();
var info = new TargetInfo
{
ObjectId = 0x5000AAAAu,
Status = TargetStatus.Ok,
TargetPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
InterpolatedPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
};
mm.HandleUpdateTarget(info); // no moveto yet — no throw
// The V2 "uninitialized type-6 stall": MoveToObject defers its node
// build to the FIRST HandleUpdateTarget delivery.
h.ContactValue = true;
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
mm.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToObject,
ObjectId = 0x5000AAAAu,
TopLevelId = 0x5000AAAAu,
Pos = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity),
Params = new MovementParameters(),
});
Assert.False(h.Manager.Initialized);
mm.HandleUpdateTarget(info);
Assert.True(h.Manager.Initialized);
}
}