using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using Xunit; namespace AcDream.Core.Tests.Physics.Motion; /// /// R5-V5 — facade conformance (retail struct /// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract /// docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md). /// 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). /// public sealed class MovementManagerTests { /// 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. 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; } /// Facade over the shared MoveToManagerHarness: the harness's /// REAL MotionInterpreter is the minterp child; the harness's REAL /// seam-scripted MoveToManager arrives via /// (the acdream stand-in for retail's physics_obj/weenie_obj /// backpointers that MakeMoveToManager constructs from). 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_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_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); } }