diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs new file mode 100644 index 00000000..a41f859f --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MoveToManager.cs @@ -0,0 +1,1592 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics.Motion; + +// ───────────────────────────────────────────────────────────────────────────── +// MoveToManager — R4-V2 verbatim port of retail's MoveToManager +// (acclient.h:31473, struct #3462; docs/research/2026-07-03-r4-moveto/ +// r4-moveto-decomp.md, all 33 members, addresses 0x00529010-0x0052a987). +// +// Retail chain this class stands in for (r4-port-plan.md §4): +// MovementManager::PerformMovement (types 6-9) → MoveToManager::PerformMovement +// → MoveToManager entry points (MoveToObject/MoveToPosition/TurnToObject/ +// TurnToHeading) → node plan (pending_actions) → BeginNextNode → +// BeginMoveForward/BeginTurnToHeading → _DoMotion/_StopMotion → +// CMotionInterp.adjust_motion → DoInterpretedMotion/StopInterpretedMotion. +// +// MovementManager itself is R5 scope (r4-port-plan.md §5 "do-not-invent") — +// this class is bound DIRECTLY to the owning entity's MotionInterpreter by the +// (future) V4/V5 orchestrator; the type-6..9 dispatch stays at the existing +// GameWindow/controller call sites until R5 grows the relay. +// +// TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo +// bodies were NOT extracted (call shapes only, decomp §9f/§9g) — R5 scope. +// This class exposes them as ctor-injected seams (Action/Func delegates), +// matching the MotionInterpreter Action? seam convention +// (UnstickFromObject/InterruptCurrentMovement/RemoveLinkAnimations/ +// InitializeMotionTables/CheckForCompletedMotions). +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// R4-V2 — verbatim port of retail's MoveToManager (acclient.h:31473, +/// struct #3462). Drives an entity's chase/flee/turn-to/turn-to-heading +/// state machine: builds a small node plan (, +/// pending_actions), steps it every tick via , +/// and issues the resulting motion commands through the SAME +/// / +/// +/// seam every other interpreted motion uses (_DoMotion/_StopMotion, +/// §7a/§7b — MoveToManager NEVER calls DoMotion, set_hold_run, or +/// any raw-state API directly). +/// +/// +/// Construction contract (r4-port-plan.md §4): ctor-injected with the +/// entity's (the _DoMotion target) plus +/// a set of seam delegates standing in for retail's CPhysicsObj +/// position/heading/body-shape accessors and the not-yet-ported +/// TargetManager/StickyManager (R5). All seam delegates are REQUIRED +/// (non-nullable) except the three explicitly retail-optional ones +/// (, , ) +/// which mirror the no-op-until-bound convention already used by +/// / +/// . +/// +/// +/// +/// The "no physics_obj" guard. Retail gates almost every member on +/// this->physics_obj != 0 (a raw pointer that can be null before +/// SetPhysicsObject is called, or after the owning CPhysicsObj +/// is destroyed). This port models the SAME guard via +/// (settable, defaults true) rather than requiring callers to pass a nullable +/// body reference through every seam — the seam delegates themselves are +/// assumed valid whenever is true (matching +/// retail's single non-null pointer covering the whole entry-point family). +/// +/// +public sealed class MoveToManager +{ + private readonly MotionInterpreter _interp; + + // ── seam delegates (ctor-injected; r4-port-plan.md §4) ───────────────── + + /// Retail CPhysicsObj::StopCompletely — routes through + /// MovementStruct{type=5} to the SAME interp + /// ( pre-R5; direct call is + /// the same body). + private readonly Action _stopCompletely; + + /// Retail physics_obj->m_position read (world position + /// + cell id) — the CURRENT position, sampled fresh every call site that + /// reads myPos/curPos. + private readonly Func _getPosition; + + /// Retail CPhysicsObj::get_heading — current compass + /// heading, degrees (P5 convention). + private readonly Func _getHeading; + + /// Retail CPhysicsObj::set_heading(heading, send) — the + /// ONE heading snap in the whole family + /// ('s arrival snap, decomp §6c + /// @0052a146). send flags the outbound network echo (remotes: + /// no-op send per the wiring contract). + private readonly Action _setHeading; + + /// Retail CPhysicsObj::GetRadius — the MOVER's own + /// radius (feeds 's cylinder-distance + /// own-side argument). + private readonly Func _getOwnRadius; + + /// Retail CPhysicsObj::GetHeight — the MOVER's own + /// height. + private readonly Func _getOwnHeight; + + /// Retail physics_obj->transient_state & 1 — + /// CONTACT bit ('s tick gate, decomp §6a). + private readonly Func _contact; + + /// Retail CPhysicsObj::IsInterpolating → + /// PositionManager::IsInterpolating — consumed by the + /// fail-progress stall tests (decomp §6b/§6c). + private readonly Func _isInterpolating; + + /// Retail CPhysicsObj::get_velocity — feeds the Phase-3 + /// TargetManager quantum retune (decomp §6b Phase 3). + private readonly Func _getVelocity; + + /// Retail physics_obj->id — the mover's own object id + /// (self-target detection in MoveToObject/TurnToObject, §3b/§3d). + private readonly Func _getSelfId; + + /// Retail CPhysicsObj::set_target(context_id, object_id, + /// radius, quantum)TargetManager::SetTarget (call shape + /// only, decomp §9f — R5 owns the body). MoveToManager always passes + /// (0, top_level_object_id, 0.5f, 0.0). + private readonly Action _setTarget; + + /// Retail CPhysicsObj::clear_target → + /// TargetManager::ClearTarget (call shape only). + private readonly Action _clearTarget; + + /// Retail CPhysicsObj::get_target_quantum (call shape + /// only; ACE: returns TargetInfo.Quantum, default 0). + private readonly Func _getTargetQuantum; + + /// Retail CPhysicsObj::set_target_quantum → + /// TargetManager::SetTargetQuantum (call shape only). + private readonly Action _setTargetQuantum; + + /// Retail CPhysicsObj::unstick_from_object → + /// PositionManager::UnStick (call shape only, R5 body). Called at + /// the head of every (§3a). Optional — + /// null is a silent no-op, matching + /// 's convention. + public Action? Unstick { get; set; } + + /// Retail PositionManager::StickTo(object_id, radius, + /// height) (call shape only, R5 StickyManager body) — the sticky + /// arrival handoff in (§4b). Optional — + /// null is a silent no-op. + public Action? StickTo { get; set; } + + /// + /// CLIENT ADDITION — NOT retail (decomp §7e / do-not-invent list): + /// CleanUpAndCallWeenie contains no weenie call in this build + /// (the name is vestigial; body ≡ CleanUp + StopCompletely). This seam + /// stands in for ACE's server-side OnMoveComplete notification so + /// the App layer can re-anchor AD-27 (Use/PickUp re-send on arrival). + /// Fires from ONLY (never from plain + /// /). Optional — null is + /// a silent no-op. + /// + public Action? MoveToComplete { get; set; } + + /// Retail Timer::cur_time — injectable clock (tests drive + /// this explicitly; production binds to the real wall/game clock). + /// Defaults to a monotonically-increasing stub if not overridden via the + /// ctor (every call site needs SOME value; production always supplies + /// one). + private readonly Func _curTime; + + /// + /// Retail's physics_obj != 0 guard, modeled as a settable flag + /// (see the class-level "no physics_obj" doc). Defaults true — most + /// production entities always have a body by construction time. + /// + public bool HasPhysicsObj { get; set; } = true; + + public MoveToManager( + MotionInterpreter interp, + Action stopCompletely, + Func getPosition, + Func getHeading, + Action setHeading, + Func getOwnRadius, + Func getOwnHeight, + Func contact, + Func isInterpolating, + Func getVelocity, + Func getSelfId, + Action setTarget, + Action clearTarget, + Func getTargetQuantum, + Action setTargetQuantum, + Func? curTime = null) + { + _interp = interp ?? throw new ArgumentNullException(nameof(interp)); + _stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely)); + _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition)); + _getHeading = getHeading ?? throw new ArgumentNullException(nameof(getHeading)); + _setHeading = setHeading ?? throw new ArgumentNullException(nameof(setHeading)); + _getOwnRadius = getOwnRadius ?? throw new ArgumentNullException(nameof(getOwnRadius)); + _getOwnHeight = getOwnHeight ?? throw new ArgumentNullException(nameof(getOwnHeight)); + _contact = contact ?? throw new ArgumentNullException(nameof(contact)); + _isInterpolating = isInterpolating ?? throw new ArgumentNullException(nameof(isInterpolating)); + _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity)); + _getSelfId = getSelfId ?? throw new ArgumentNullException(nameof(getSelfId)); + _setTarget = setTarget ?? throw new ArgumentNullException(nameof(setTarget)); + _clearTarget = clearTarget ?? throw new ArgumentNullException(nameof(clearTarget)); + _getTargetQuantum = getTargetQuantum ?? throw new ArgumentNullException(nameof(getTargetQuantum)); + _setTargetQuantum = setTargetQuantum ?? throw new ArgumentNullException(nameof(setTargetQuantum)); + + double t = 0.0; + _curTime = curTime ?? (() => t += 1.0 / 30.0); + + InitializeLocalVariables(); + } + + // ── state block (retail acclient.h:31473 field-for-field) ────────────── + + /// +0x00 movement_type. + public MovementType MovementTypeState { get; private set; } = MovementType.Invalid; + + /// + /// Retail's default-constructed Position (identity + /// CellFrame, cell id 0) — NOT C#'s default(Position), + /// whose default(Quaternion) is the ZERO quaternion (0,0,0,0), + /// not the identity rotation (0,0,0,1). GetHeading on a zero + /// quaternion is degenerate (does not read as "heading 0" — the + /// underlying Vector3.Transform collapses to zero). Retail's + /// ctor (§1a) and (§1c) both + /// reset these fields to a genuine identity frame. + /// + private static readonly Position IdentityPosition = new(0u, Vector3.Zero, Quaternion.Identity); + + /// +0x04 sought_position. + public Position SoughtPosition { get; private set; } = IdentityPosition; + + /// +0x4C current_target_position. + public Position CurrentTargetPosition { get; private set; } = IdentityPosition; + + /// +0x94 starting_position. + public Position StartingPosition { get; private set; } = IdentityPosition; + + /// +0xDC movement_params (the 10-field copy every entry + /// point performs). + public MovementParameters Params { get; private set; } = new(); + + /// +0x108 previous_heading. + public float PreviousHeading { get; private set; } + + /// +0x10C previous_distance. + public float PreviousDistance { get; private set; } + + /// +0x110 previous_distance_time. + public double PreviousDistanceTime { get; private set; } + + /// +0x118 original_distance. + public float OriginalDistance { get; private set; } + + /// +0x120 original_distance_time. + public double OriginalDistanceTime { get; private set; } + + /// +0x128 fail_progress_count — WRITE-ONLY (decomp §8; + /// do-not-invent: no give-up threshold exists in retail). Exposed for + /// conformance-test inspection only. + public uint FailProgressCount { get; private set; } + + /// +0x12C sought_object_id. + public uint SoughtObjectId { get; private set; } + + /// +0x130 top_level_object_id. + public uint TopLevelObjectId { get; private set; } + + /// +0x134 sought_object_radius. + public float SoughtObjectRadius { get; private set; } + + /// +0x138 sought_object_height. + public float SoughtObjectHeight { get; private set; } + + /// +0x13C current_command. + public uint CurrentCommand { get; private set; } + + /// +0x140 aux_command. + public uint AuxCommand { get; private set; } + + /// +0x144 moving_away. + public bool MovingAway { get; private set; } + + /// +0x148 initialized. + public bool Initialized { get; private set; } + + /// +0x14C/+0x150 pending_actions (DLList) — ported as a + /// managed of , tail-append + /// / head-pop (matching InsertAfter/RemovePendingActionsHead + /// shape). Exposed read-only for conformance tests. + private readonly LinkedList _pendingActions = new(); + + /// Read-only inspection surface (tests): the node queue in + /// head-to-tail order. + public IEnumerable PendingActions => _pendingActions; + + // ── 1. Creation / lifecycle ───────────────────────────────────────────── + + /// + /// Retail MoveToManager::InitializeLocalVariables + /// (00529250, raw 306490-306534). VERBATIM per decomp §1c: zeroes + /// (Invalid) and the params BITFIELD + + /// CONTEXT ID ONLY (movement_params.__inner0 = 0; + /// movement_params.context_id = 0; — NOT ACE's A2/A3 + /// full-struct-reset transposition; the scalar param fields keep their + /// STALE values because every entry point copies all ten fields anyway), + /// resets both progress-clock pairs to FLT_MAX/now ( + /// /), + /// =0, =0, + /// /=0, + /// /=false, resets + /// / to + /// cell 0 + identity frame ( is NOT + /// touched here), and zeroes / + /// // + /// . Does NOT drain + /// — callers (/ + /// ) drain first. + /// + public void InitializeLocalVariables() + { + MovementTypeState = MovementType.Invalid; + + // movement_params.__inner0 = 0 (bitfield CLEARED, not re-defaulted); + // movement_params.context_id = 0. Scalars stay whatever they were — + // model this by clearing only the bitfield-backed bools + ContextId + // on the EXISTING Params instance (do not replace it — a replace + // would also reset the scalars, diverging from retail's field-level + // clear). + Params.CanWalk = false; + Params.CanRun = false; + Params.CanSidestep = false; + Params.CanWalkBackwards = false; + Params.CanCharge = false; + Params.FailWalk = false; + Params.UseFinalHeading = false; + Params.Sticky = false; + Params.MoveAway = false; + Params.MoveTowards = false; + Params.UseSpheres = false; + Params.SetHoldKey = false; + Params.Autonomous = false; + Params.ModifyRawState = false; + Params.ModifyInterpretedState = false; + Params.CancelMoveTo = false; + Params.StopCompletelyFlag = false; + Params.DisableJumpDuringLink = false; + Params.ContextId = 0; + + PreviousDistance = float.MaxValue; + PreviousDistanceTime = _curTime(); + OriginalDistance = float.MaxValue; + OriginalDistanceTime = _curTime(); + + PreviousHeading = 0f; + FailProgressCount = 0; + CurrentCommand = 0; + AuxCommand = 0; + MovingAway = false; + Initialized = false; + + SoughtPosition = IdentityPosition; + CurrentTargetPosition = IdentityPosition; + + SoughtObjectId = 0; + TopLevelObjectId = 0; + SoughtObjectRadius = 0f; + SoughtObjectHeight = 0f; + } + + /// + /// Retail MoveToManager::Destroy (005294b0, raw + /// 306618-306663): drains then tailcalls + /// . + /// + public void Destroy() + { + _pendingActions.Clear(); + InitializeLocalVariables(); + } + + /// + /// Retail MoveToManager::is_moving_to (00529220, raw + /// 306464-306470): movement_type != Invalid. + /// + public bool IsMovingTo() => MovementTypeState != MovementType.Invalid; + + // ── 3. Entry points (movement_type setters) ───────────────────────────── + + /// + /// Retail MoveToManager::PerformMovement (0052a900, raw + /// 307871-307904). VERBATIM per decomp §3a: cancels any in-flight moveto + /// ( with + /// 0x36) and unsticks FIRST, unconditionally, before the 4-way type + /// dispatch (MoveToObject/MoveToPosition/TurnToObject/TurnToHeading). + /// Always returns — moveto errors surface + /// via , never via this return value (decomp + /// §2b note, carried through §3a). + /// + public WeenieError PerformMovement(MovementStruct mvs) + { + CancelMoveTo(WeenieError.ActionCancelled); + Unstick?.Invoke(); + + switch (mvs.Type) + { + case MovementType.MoveToObject: + MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params ?? new MovementParameters()); + break; + case MovementType.MoveToPosition: + MoveToPosition(mvs.Pos, mvs.Params ?? new MovementParameters()); + break; + case MovementType.TurnToObject: + TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params ?? new MovementParameters()); + break; + case MovementType.TurnToHeading: + TurnToHeading(mvs.Params ?? new MovementParameters()); + break; + } + + return WeenieError.None; + } + + /// + /// Retail MoveToManager::MoveToObject (00529680, raw + /// 306756-306817). VERBATIM per decomp §3b: StopCompletely, snapshot + /// , store sought object id/radius/height, + /// set =MoveToObject, + /// , copy all ten + /// fields into , =false. + /// Self-target ( == the mover's own id) → + /// + StopCompletely (degenerate instant no-op). + /// Otherwise → (0, topLevelId, 0.5f, 0.0) — the + /// P4 TargetTracker seam. NO nodes are queued yet — object moves are + /// deferred until the first callback + /// (§6). NOTE: is PRESERVED here + /// (unlike position/turn-heading moves, which force it off — §3c/§3e). + /// + public void MoveToObject(uint objectId, uint topLevelId, float radius, float height, MovementParameters p) + { + if (!HasPhysicsObj) return; + + _stopCompletely(); + StartingPosition = _getPosition(); + SoughtObjectId = objectId; + SoughtObjectRadius = radius; + SoughtObjectHeight = height; + MovementTypeState = MovementType.MoveToObject; + TopLevelObjectId = topLevelId; + CopyParams(p); + Initialized = false; + + if (topLevelId == _getSelfId()) + { + CleanUp(); + _stopCompletely(); + } + else + { + _setTarget(0, TopLevelObjectId, 0.5f, 0.0); + } + } + + /// + /// Retail MoveToManager::MoveToPosition (0052a240, raw + /// 307521-307593). VERBATIM per decomp §3c: StopCompletely, snapshot + /// =, + /// =0, compute + /// , compute the heading-to-target minus + /// current heading (epsilon-snapped/wrapped, same [0,360) normalization + /// used throughout), to see + /// if ANY motion is needed — if so, queue + /// [TurnToHeading(face target)] → [MoveToPosition]. If + /// (0x40) is set, ALSO + /// queue a final TurnToHeading(desired_heading) (absolute, unlike + /// 's relative form). Then snapshot + /// /, set + /// =MoveToPosition, copy all ten + /// fields into , CLEAR the + /// sticky bit (0x80 — position moves and heading turns can never stick; + /// only object moves preserve it), then . + /// + /// + /// Reads the ARGUMENT's + /// (), NOT a stale member — the do-not-invent list's + /// "A1 stale-member UseFinalHeading read" ACE-ism is explicitly NOT + /// copied here. + /// + /// + public void MoveToPosition(Position target, MovementParameters p) + { + if (!HasPhysicsObj) return; + + _stopCompletely(); + CurrentTargetPosition = target; + SoughtObjectRadius = 0f; + + float dist = GetCurrentDistance(); + + Vector3 myPos = _getPosition().Frame.Origin; + Vector3 targetPos = target.Frame.Origin; + float toTargetHeading = MoveToMath.PositionHeading(myPos, targetPos); + float headingDiff = toTargetHeading - _getHeading(); + if (MathF.Abs(headingDiff) < MoveToMath.Epsilon) headingDiff = 0f; + if (headingDiff < -MoveToMath.Epsilon) headingDiff += 360f; + + p.GetCommand(dist, headingDiff, out uint cmd, out _, out _); + if (cmd != 0) + { + AddTurnToHeadingNode(toTargetHeading); + AddMoveToPositionNode(); + } + + if (p.UseFinalHeading) + { + AddTurnToHeadingNode(p.DesiredHeading); + } + + SoughtPosition = target; + StartingPosition = _getPosition(); + MovementTypeState = MovementType.MoveToPosition; + CopyParams(p); + Params.Sticky = false; // __inner0 &= 0xffffff7f + + BeginNextNode(); + } + + /// + /// Retail MoveToManager::TurnToObject (005297d0, raw + /// 306820-306882). VERBATIM per decomp §3d, INCLUDING the desired-heading + /// clobber quirk: .StopCompletelyFlag's underlying bit + /// (0x10000) — here, — + /// gates the StopCompletely call CONDITIONALLY (unlike MoveToObject/ + /// MoveToPosition's UNCONDITIONAL stop). Seeds + /// current_target_position.frame's heading from + /// .DesiredHeading — but this write is DISCARDED: + /// later overwrites + /// wholesale and reads + /// 's heading instead (which is 0 after + /// for a fresh manager). This is a + /// RETAIL QUIRK (ACE MoveToManager.cs:246 matches verbatim) — do NOT + /// "fix" it; the effective final heading is simply "face the object". + /// Self-target → + StopCompletely. Otherwise → + /// =false + (0, + /// topLevelId, 0.5f, 0.0). Deferred like MoveToObject — no nodes queued + /// here; queues on the first target + /// callback. + /// + public void TurnToObject(uint objectId, uint topLevelId, MovementParameters p) + { + if (!HasPhysicsObj) return; + + if (p.StopCompletelyFlag) + { + _stopCompletely(); + } + + MovementTypeState = MovementType.TurnToObject; + SoughtObjectId = objectId; + + // Frame::set_heading(¤t_target_position.frame, desired_heading) + // — clobbered before any read; see the quirk note above. + CurrentTargetPosition = CurrentTargetPosition with + { + Frame = new CellFrame( + CurrentTargetPosition.Frame.Origin, + MoveToMath.SetHeading(CurrentTargetPosition.Frame.Orientation, p.DesiredHeading)), + }; + + TopLevelObjectId = topLevelId; + CopyParams(p); + + if (topLevelId == _getSelfId()) + { + CleanUp(); + _stopCompletely(); + } + else + { + Initialized = false; + _setTarget(0, topLevelId, 0.5f, 0.0); + } + } + + /// + /// Retail MoveToManager::TurnToHeading (0052a630, raw + /// 307706-307772). VERBATIM per decomp §3e: conditional StopCompletely + /// (0x10000 stop_completely bit, same shape as + /// ), copy all ten fields, + /// CLEAR sticky (0x80), seed 's frame heading + /// from .DesiredHeading, set + /// =TurnToHeading, queue ONE + /// (type TurnToHeading, heading=DesiredHeading) + /// directly (inlined AddTurnToHeadingNode shape — same effect), + /// then IMMEDIATE — unlike ACE's A4 gap + /// (one-tick-late), retail calls BeginNextNode synchronously + /// inside this entry point. is NOT set here + /// (stays whatever it was) — the gate (§6a) passes + /// anyway because is 0 for non-object + /// moves (TurnToHeading never sets it). + /// + public void TurnToHeading(MovementParameters p) + { + if (!HasPhysicsObj) return; + + if (p.StopCompletelyFlag) + { + _stopCompletely(); + } + + CopyParams(p); + Params.Sticky = false; // __inner0 &= 0xffffff7f + + SoughtPosition = SoughtPosition with + { + Frame = new CellFrame( + SoughtPosition.Frame.Origin, + MoveToMath.SetHeading(SoughtPosition.Frame.Orientation, p.DesiredHeading)), + }; + + MovementTypeState = MovementType.TurnToHeading; + + _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, p.DesiredHeading)); + + BeginNextNode(); + } + + // ── 4. Node stepping ───────────────────────────────────────────────────── + + /// + /// Retail MoveToManager::AddTurnToHeadingNode (00529530, + /// raw 306667-306685): tail-append a TurnToHeading node. + /// + public void AddTurnToHeadingNode(float heading) + => _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, heading)); + + /// + /// Retail MoveToManager::AddMoveToPositionNode (00529580, + /// raw 306689-306706): tail-append a MoveToPosition node (heading + /// unused). + /// + public void AddMoveToPositionNode() + => _pendingActions.AddLast(new MoveToNode(MovementType.MoveToPosition, 0f)); + + /// + /// Retail MoveToManager::RemovePendingActionsHead + /// (00529380, raw 306538-306550): unlink + delete the head node. + /// + public void RemovePendingActionsHead() + { + if (_pendingActions.First is not null) + _pendingActions.RemoveFirst(); + } + + /// + /// Retail MoveToManager::BeginNextNode (00529cb0, raw + /// 307123-307171). VERBATIM per decomp §4b — THE STICKY HANDOFF: if a + /// node exists, tailcall (type + /// MoveToPosition) or (type + /// TurnToHeading); an unknown node type stalls (defensive, matches the + /// raw's if/if shape — no else fallthrough). Empty queue = + /// moveto complete: if (byte0 + /// sign bit 0x80) is set, READ / + /// / + /// BEFORE (which zeroes them), THEN CleanUp, THEN + /// StopCompletely, THEN hand off to (R5 + /// StickyManager seam) with the pre-CleanUp values. Non-sticky empty + /// queue: CleanUp + StopCompletely only. + /// + public void BeginNextNode() + { + if (_pendingActions.First is not null) + { + MovementType type = _pendingActions.First.Value.Type; + if (type == MovementType.MoveToPosition) + { + BeginMoveForward(); + return; + } + if (type == MovementType.TurnToHeading) + { + BeginTurnToHeading(); + return; + } + return; // unknown node type: stall (defensive) + } + + if (Params.Sticky) + { + float height = SoughtObjectHeight; + float radius = SoughtObjectRadius; + uint tlid = TopLevelObjectId; + + CleanUp(); + if (HasPhysicsObj) _stopCompletely(); + + StickTo?.Invoke(tlid, radius, height); + return; + } + + CleanUp(); + if (HasPhysicsObj) _stopCompletely(); + } + + /// + /// Retail MoveToManager::BeginMoveForward (00529a00, raw + /// 306957-307042). VERBATIM per decomp §4c: no physics_obj → + /// (). + /// Compute distance + heading-diff-to-target (same epsilon + /// snap/normalize as ), then + /// . If no motion is needed + /// (already in range) — pop the head node and + /// . Otherwise build a fresh LOCAL params + /// (defaults; CLEARED so + /// _DoMotion doesn't cancel THIS moveto; speed copied from + /// ), issue via — on error, + /// with that error and return. On success, + /// record /, write + /// the CHOSEN hold key BACK to the stored + /// (HoldKeyToApply), and seed BOTH progress-clock pairs + /// (/ = dist, + /// both times = now). + /// + public void BeginMoveForward() + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + float dist = GetCurrentDistance(); + + Vector3 myPos = _getPosition().Frame.Origin; + Vector3 targetPos = CurrentTargetPosition.Frame.Origin; + float heading = MoveToMath.PositionHeading(myPos, targetPos) - _getHeading(); + if (MathF.Abs(heading) < MoveToMath.Epsilon) heading = 0f; + if (heading < -MoveToMath.Epsilon) heading += 360f; + + Params.GetCommand(dist, heading, out uint cmd, out HoldKey holdKey, out bool movingAway); + + if (cmd == 0) + { + RemovePendingActionsHead(); + BeginNextNode(); + return; + } + + var localParams = new MovementParameters + { + HoldKeyToApply = holdKey, + CancelMoveTo = false, // bitfield &= 0xffff7fff + Speed = Params.Speed, + }; + + WeenieError err = _DoMotion(cmd, localParams); + if (err != WeenieError.None) + { + CancelMoveTo(err); + return; + } + + CurrentCommand = cmd; + MovingAway = movingAway; + Params.HoldKeyToApply = holdKey; + PreviousDistance = dist; + PreviousDistanceTime = _curTime(); + OriginalDistance = dist; + OriginalDistanceTime = _curTime(); + } + + /// + /// Retail MoveToManager::BeginTurnToHeading (00529b90, raw + /// 307046-307120). VERBATIM per decomp §4d: empty queue OR no + /// physics_obj → (NoPhysicsObject, per A10 — + /// NOT ACE's throw). If animations are still pending + /// (), WAIT (return — do + /// not start the turn yet). Otherwise read the head node's heading, call + /// with the CONSTANT + /// (mirror explicitly disabled — + /// direction pick uses the raw ≤180 test, not the mirrored value): + /// diff > 180 → check "already there" (diff + eps >= + /// 360) and pop+advance if so, else TurnLeft; diff <= 180 + /// → check "already there" (diff <= eps) and pop+advance if + /// so, else TurnRight. Issue the turn via with a + /// fresh local params (CancelMoveTo cleared, speed + hold_key copied + /// from ) — on error, . On + /// success, record and store the REMAINING + /// DIFF (not a heading!) into — THE QUIRK: + /// 's progress test then compares a + /// live HEADING against this DIFF-shaped seed on its first tick. Keep + /// verbatim (ACE matches: PreviousHeading = diff). + /// + public void BeginTurnToHeading() + { + if (_pendingActions.First is null || !HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + if (_interp.MotionsPending()) + { + return; + } + + float targetHeading = _pendingActions.First.Value.Heading; + float diff = MoveToMath.HeadingDiff(targetHeading, _getHeading(), MotionCommand.TurnRight); + + uint turn; + if (diff > 180f) + { + if (diff + MoveToMath.Epsilon >= 360f) + { + RemovePendingActionsHead(); + BeginNextNode(); + return; + } + turn = MotionCommand.TurnLeft; + } + else + { + if (diff <= MoveToMath.Epsilon) + { + RemovePendingActionsHead(); + BeginNextNode(); + return; + } + turn = MotionCommand.TurnRight; + } + + var localParams = new MovementParameters + { + CancelMoveTo = false, + Speed = Params.Speed, + HoldKeyToApply = Params.HoldKeyToApply, + }; + + WeenieError err = _DoMotion(turn, localParams); + if (err != WeenieError.None) + { + CancelMoveTo(err); + return; + } + + CurrentCommand = turn; + PreviousHeading = diff; // NOTE: the remaining DIFF, not a heading — retail quirk, keep verbatim. + } + + // ── 5. Distance / progress / command-selection helpers ───────────────── + + /// + /// Retail MoveToManager::GetCurrentDistance (005291b0, raw + /// 306435-306460). VERBATIM per decomp §5a: no physics_obj → 0 (retail's + /// x87-garbled void return; modeled as 0 since every caller only uses + /// this when is already known true). When + /// (0x400) is NOT set → plain + /// center (Euclidean) distance to . + /// When SET → using the + /// mover's own radius/height (/ + /// ) vs the stored + /// / — + /// object moves (which set sought radius/height and get use_spheres on + /// the wire) use edge-to-edge distance; position moves use center + /// distance. + /// + public float GetCurrentDistance() + { + if (!HasPhysicsObj) return 0f; + + Vector3 myPos = _getPosition().Frame.Origin; + Vector3 targetPos = CurrentTargetPosition.Frame.Origin; + + if (!Params.UseSpheres) + { + return Vector3.Distance(myPos, targetPos); + } + + return MoveToMath.CylinderDistance( + _getOwnRadius(), _getOwnHeight(), myPos, + SoughtObjectRadius, SoughtObjectHeight, targetPos); + } + + /// + /// Retail MoveToManager::CheckProgressMade (005290f0, raw + /// 306385-306431). VERBATIM per decomp §5b: evaluated only after a + /// 1-SECOND window since (before that, + /// returns true — "OK, too soon to judge"). Progress = distance closed + /// (or opened, when ) since the last checkpoint; + /// requires BOTH the incremental rate (since + /// ) AND the overall rate (since + /// ) to be ≥ 0.25 units/second. The + /// incremental checkpoint (/ + /// ) only advances when the + /// incremental rate passes. + /// + public bool CheckProgressMade(float currentDistance) + { + double elapsed = _curTime() - PreviousDistanceTime; + if (elapsed <= 1.0) return true; + + float progress = MovingAway + ? currentDistance - PreviousDistance + : PreviousDistance - currentDistance; + + if (progress / (float)elapsed >= 0.25f) + { + PreviousDistance = currentDistance; + PreviousDistanceTime = _curTime(); + + float total = MovingAway + ? currentDistance - OriginalDistance + : OriginalDistance - currentDistance; + float totalRate = total / (float)(_curTime() - OriginalDistanceTime); + + if (totalRate >= 0.25f) return true; + } + + return false; + } + + // ── 6. Per-tick drivers + target updates ──────────────────────────────── + + /// + /// Retail MoveToManager::UseTime (0052a780, raw + /// 307776-307798) — THE TICK GATE. VERBATIM per decomp §6a: three gates, + /// ALL must pass: (1) grounded — () (CONTACT + /// transient-state bit; no moveto progress mid-air, + /// resumes on landing); (2) a pending node exists; (3) object-moves + /// ( != 0 AND + /// != Invalid) must be + /// — i.e. the first target update has arrived. + /// Dispatches to (node type 7) or + /// (node type 9). + /// + public void UseTime() + { + if (!HasPhysicsObj || !_contact()) return; + + if (_pendingActions.First is null) return; + + bool objectMoveGate = TopLevelObjectId == 0 || MovementTypeState == MovementType.Invalid || Initialized; + if (!objectMoveGate) return; + + MovementType type = _pendingActions.First.Value.Type; + if (type == MovementType.MoveToPosition) + { + HandleMoveToPosition(); + } + else if (type == MovementType.TurnToHeading) + { + HandleTurnToHeading(); + } + } + + /// + /// Retail MoveToManager::HandleMoveToPosition (00529d80, + /// raw 307187-307438) — the big per-tick driver. VERBATIM per decomp + /// §6b, three phases: + /// + /// Phase 1 — aux turn steering (only when NOT + /// animating, i.e. is + /// false): compute the desired heading + /// ( offset + + /// heading-to-target, wrapped [0,360)) minus current heading + /// (epsilon-snapped/normalized). If inside the [0,20]∪[340,360) deadband, + /// stop any active aux turn. Otherwise pick TurnLeft (diff ≥ 180) or + /// TurnRight, and issue it via ONLY if it differs + /// from the currently-running (no redundant + /// re-issue). While animating, stop any active aux turn instead (can't + /// steer mid-animation). + /// Phase 2 — arrival / progress: compute + /// distance, run . On NO progress + /// (returns false): increment (write-only, + /// §8) ONLY if neither interpolating nor animating (a stall is only + /// "real" when nothing else explains the lack of motion). On progress + /// (true): reset the fail counter, then test arrival — + /// moving_away ? dist >= MinDistance : dist <= DistanceToObject. + /// NOT arrived: check against + /// the distance traveled from — + /// (0x3D) if exceeded. ARRIVED: + /// pop the head node, stop the current+aux motions, clear both command + /// fields, . + /// Phase 3 — TargetManager quantum retune + /// (object moves only, gated the SAME way as the UseTime object-move + /// gate): while chasing an object faster than 0.1 units/s, retune the + /// tracker's update quantum to the estimated time-to-arrival + /// (distance/speed) whenever it drifts ≥1 second from the current + /// quantum. + /// + /// ACE-divergence trap (do-not-invent): retail has NO + /// set_heading call anywhere in this method (ACE's "custom: sync + /// for server ticrate" addition is NOT ported). + /// + public void HandleMoveToPosition() + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + Vector3 curPos = _getPosition().Frame.Origin; + + var localParams = new MovementParameters + { + Speed = Params.Speed, + CancelMoveTo = false, + HoldKeyToApply = Params.HoldKeyToApply, + }; + + // ---- PHASE 1: aux turn steering ---- + if (_interp.MotionsPending()) + { + if (AuxCommand != 0) + { + _StopMotion(AuxCommand, localParams); + AuxCommand = 0; + } + } + else + { + Vector3 targetPos = CurrentTargetPosition.Frame.Origin; + float toTargetHeading = MoveToMath.PositionHeading(curPos, targetPos); + float heading = Params.GetDesiredHeading(CurrentCommand, MovingAway) + toTargetHeading; + if (heading >= 360f) heading -= 360f; + + float diff = heading - _getHeading(); + if (MathF.Abs(diff) < MoveToMath.Epsilon) diff = 0f; + if (diff < -MoveToMath.Epsilon) diff += 360f; + + if (diff <= 20f || diff >= 340f) + { + if (AuxCommand != 0) + { + _StopMotion(AuxCommand, localParams); + AuxCommand = 0; + } + } + else + { + uint turn = diff >= 180f ? MotionCommand.TurnLeft : MotionCommand.TurnRight; + if (turn != AuxCommand) + { + _DoMotion(turn, localParams); + AuxCommand = turn; + } + } + } + + // ---- PHASE 2: arrival / progress ---- + float dist = GetCurrentDistance(); + if (!CheckProgressMade(dist)) + { + if (!_isInterpolating() && !_interp.MotionsPending()) + { + FailProgressCount += 1; + } + } + else + { + FailProgressCount = 0; + + bool arrived = MovingAway + ? dist >= Params.MinDistance + : dist <= Params.DistanceToObject; + + if (!arrived) + { + float startDist = Vector3.Distance(StartingPosition.Frame.Origin, _getPosition().Frame.Origin); + if (startDist > Params.FailDistance) + { + CancelMoveTo(WeenieError.YouChargedTooFar); + } + } + else + { + RemovePendingActionsHead(); + _StopMotion(CurrentCommand, localParams); + CurrentCommand = 0; + if (AuxCommand != 0) + { + _StopMotion(AuxCommand, localParams); + AuxCommand = 0; + } + BeginNextNode(); + } + } + + // ---- PHASE 3: TargetManager quantum retune (object moves only) ---- + if (TopLevelObjectId != 0 && MovementTypeState != MovementType.Invalid) + { + Vector3 v = _getVelocity(); + float speed = v.Length(); + if (speed > 0.1) + { + float eta = dist / speed; + if (MathF.Abs(eta - (float)_getTargetQuantum()) >= 1.0f) + { + _setTargetQuantum(eta); + } + } + } + } + + /// + /// Retail MoveToManager::HandleTurnToHeading (0052a0c0, raw + /// 307442-307517). VERBATIM per decomp §6c: no physics_obj → + /// (NoPhysicsObject). If NOT currently turning + /// ( isn't TurnLeft/TurnRight) — re-enter + /// (arms the turn). Otherwise: if + /// says the current heading has + /// PASSED the node's target heading (direction-aware per + /// ) — reset the fail counter, SNAP the + /// heading to the node's exact value via + /// (send:true — THE ONE heading snap in the whole family, decomp + /// §6c @0052a146), pop the node, stop the current motion, clear + /// , . Otherwise — + /// still turning: compute between + /// the LIVE current heading and (the + /// quirk-seeded DIFF from ), passing the + /// LIVE (can be TurnLeft — the mirror DOES + /// apply here, P3). If the result is inside (eps, 180) — rotational + /// progress was made: reset fail counter, update + /// to the LIVE heading. Otherwise — no + /// progress: update anyway, then increment + /// ONLY if neither interpolating nor + /// animating. + /// + public void HandleTurnToHeading() + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + uint cmd = CurrentCommand; + if (cmd != MotionCommand.TurnLeft && cmd != MotionCommand.TurnRight) + { + BeginTurnToHeading(); + return; + } + + MoveToNode head = _pendingActions.First!.Value; + float heading = _getHeading(); + + if (MoveToMath.HeadingGreater(heading, head.Heading, cmd)) + { + FailProgressCount = 0; + _setHeading(head.Heading, true); + RemovePendingActionsHead(); + + var localParams = new MovementParameters + { + CancelMoveTo = false, + HoldKeyToApply = Params.HoldKeyToApply, + }; + _StopMotion(CurrentCommand, localParams); + CurrentCommand = 0; + BeginNextNode(); + return; + } + + float diff = MoveToMath.HeadingDiff(heading, PreviousHeading, cmd); + if (diff < 180f && diff > MoveToMath.Epsilon) + { + FailProgressCount = 0; + PreviousHeading = heading; + return; + } + + PreviousHeading = heading; + if (!_isInterpolating() && !_interp.MotionsPending()) + { + FailProgressCount += 1; + } + } + + /// + /// Retail MoveToManager::HandleUpdateTarget (0052a7d0, raw + /// 307802-307867). VERBATIM per decomp §6d — the P4 TargetTracker feed + /// point: arrives at CONTEXT 0 only (the caller's + /// responsibility — CPhysicsObj::HandleUpdateTarget gates on + /// context_id == 0 before relaying, decomp §9f; not re-checked + /// here). No physics_obj → (NoPhysicsObject). + /// Ignore updates for any target other than + /// . TWO paths: + /// + /// Deferred start ( + /// false — the FIRST callback): self-target → snapshot both positions to + /// the mover's own current position and + /// (None) — instant success. Non-OK + /// status → ( + /// 0x38 — the target never resolved). Otherwise build the node plan via + /// (MoveToObject) or + /// (TurnToObject). + /// Retarget while running + /// ( true): non-OK status → + /// ( 0x37 — + /// was tracked, now gone). Otherwise, for MoveToObject ONLY: update + /// =interpolated, + /// =target, and RESET both + /// progress-clock pairs to FLT_MAX/now. Does NOT requeue nodes — the + /// running MoveToPosition node keeps steering toward the moved + /// each tick (Phase 1 of + /// ). TurnToObject gets NO retarget + /// handling (heading was frozen at the initial + /// callback). + /// + /// + public void HandleUpdateTarget(TargetInfo info) + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + if (TopLevelObjectId != info.ObjectId) return; + + if (!Initialized) + { + if (TopLevelObjectId == _getSelfId()) + { + Position selfPos = _getPosition(); + SoughtPosition = selfPos; + CurrentTargetPosition = selfPos; + CleanUpAndCallWeenie(WeenieError.None); + return; + } + + if (info.Status != TargetStatus.Ok) + { + CancelMoveTo(WeenieError.NoObject); + return; + } + + if (MovementTypeState == MovementType.MoveToObject) + { + MoveToObject_Internal(info.TargetPosition, info.InterpolatedPosition); + } + else if (MovementTypeState == MovementType.TurnToObject) + { + TurnToObject_Internal(info.TargetPosition); + } + } + else + { + if (info.Status != TargetStatus.Ok) + { + CancelMoveTo(WeenieError.ObjectGone); + return; + } + + if (MovementTypeState == MovementType.MoveToObject) + { + SoughtPosition = info.InterpolatedPosition; + CurrentTargetPosition = info.TargetPosition; + PreviousDistance = float.MaxValue; + PreviousDistanceTime = _curTime(); + OriginalDistance = float.MaxValue; + OriginalDistanceTime = _curTime(); + } + } + } + + /// + /// Retail MoveToManager::HitGround (00529d70, raw + /// 307175-307183): no-op when is Invalid; + /// otherwise tailcall — re-arm the moveto + /// state machine after landing (mirrors the UseTime CONTACT gate, which + /// blocks all progress while airborne). + /// + public void HitGround() + { + if (MovementTypeState == MovementType.Invalid) return; + BeginNextNode(); + } + + // ── 6f/6g. Deferred-start internals (first HandleUpdateTarget callback) ─ + + /// + /// Retail MoveToManager::MoveToObject_Internal (0052a400, + /// raw 307597-307663). VERBATIM per decomp §6f: no physics_obj → + /// (NoPhysicsObject). Snapshot + /// =, + /// =. Compute + /// heading toward the INTERPOLATED position (not the raw target — the + /// smoother tracking point) minus current heading, epsilon-snapped/ + /// normalized. against + /// (now valid — CurrentTargetPosition is + /// set) — if motion is needed, queue + /// [TurnToHeading(face interpolated)] → [MoveToPosition] (SAME + /// node plan shape as , but aimed at the + /// interpolated point). If + /// — queue a FINAL turn to heading-to-interpolated + + /// DesiredHeading (RELATIVE, unlike 's + /// absolute final heading). Set =true, then + /// . + /// + public void MoveToObject_Internal(Position target, Position interpolated) + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + SoughtPosition = interpolated; + CurrentTargetPosition = target; + + Vector3 myPos = _getPosition().Frame.Origin; + Vector3 interpPos = interpolated.Frame.Origin; + float iHeading = MoveToMath.PositionHeading(myPos, interpPos); + + float dist = GetCurrentDistance(); + + float diff = iHeading - _getHeading(); + if (MathF.Abs(diff) < MoveToMath.Epsilon) diff = 0f; + if (diff < -MoveToMath.Epsilon) diff += 360f; + + Params.GetCommand(dist, diff, out uint cmd, out _, out _); + if (cmd != 0) + { + AddTurnToHeadingNode(iHeading); + AddMoveToPositionNode(); + } + + if (Params.UseFinalHeading) + { + float final = iHeading + Params.DesiredHeading; + if (final >= 360f) final -= 360f; + AddTurnToHeadingNode(final); + } + + Initialized = true; + BeginNextNode(); + } + + /// + /// Retail MoveToManager::TurnToObject_Internal (0052a550, + /// raw 307667-307702). VERBATIM per decomp §6g: no physics_obj → + /// (NoPhysicsObject). Snapshot + /// =. Read + /// 's CURRENT heading (with §3d's quirk, this + /// is 0 for a fresh manager — the desired-heading write TurnToObject + /// seeded landed on instead and was + /// overwritten just above). Compute heading-to-target, add the sought + /// heading, fmod 360 — the final heading. Write it back into + /// 's frame, queue ONE TurnToHeading node + /// (inlined AddTurnToHeadingNode shape), set + /// =true, . With the + /// §3d quirk, the effective final heading is simply "face the object" — + /// ACE matches verbatim (MoveToManager.cs:246 + 271-276). + /// + public void TurnToObject_Internal(Position target) + { + if (!HasPhysicsObj) + { + CancelMoveTo(WeenieError.NoPhysicsObject); + return; + } + + CurrentTargetPosition = target; + + float soughtHeading = MoveToMath.GetHeading(SoughtPosition.Frame.Orientation); + + Vector3 myPos = _getPosition().Frame.Origin; + Vector3 targetPos = CurrentTargetPosition.Frame.Origin; + float targetHeading = MoveToMath.PositionHeading(myPos, targetPos); + + float final = (targetHeading + soughtHeading) % 360f; + if (final < 0f) final += 360f; + + SoughtPosition = SoughtPosition with + { + Frame = new CellFrame(SoughtPosition.Frame.Origin, MoveToMath.SetHeading(SoughtPosition.Frame.Orientation, final)), + }; + + _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, final)); + + Initialized = true; + BeginNextNode(); + } + + // ── 7. Motion issuing + cleanup family ────────────────────────────────── + + /// + /// Retail MoveToManager::_DoMotion (00529010, raw + /// 306351-306364). VERBATIM per decomp §7a: no physics_obj → 0x08 + /// (NoPhysicsObject); no motion interpreter → 0x0B + /// (NoMotionInterpreter — modeled as always-present since + /// is a required ctor field, so this branch is + /// unreachable in this port; kept in the doc for parity). Calls + /// EXPLICITLY (mutates + /// /.Speed in place — e.g. + /// promotes WalkForward+HoldKey.Run → RunForward×runRate) THEN tailcalls + /// + /// with the ADJUSTED motion id. THIS IS THE ENTIRE CMotionInterp SEAM — + /// MoveToManager never calls the raw-command DoMotion, + /// set_hold_run, or any raw-state API directly. + /// + private WeenieError _DoMotion(uint motion, MovementParameters p) + { + if (!HasPhysicsObj) return WeenieError.NoPhysicsObject; + + float speed = p.Speed; + _interp.adjust_motion(ref motion, ref speed, p.HoldKeyToApply); + p.Speed = speed; + + return _interp.DoInterpretedMotion(motion, p); + } + + /// + /// Retail MoveToManager::_StopMotion (00529080, raw + /// 306368-306381). Identical shape to : calls + /// then tailcalls + /// + /// with the ADJUSTED motion id. + /// + private WeenieError _StopMotion(uint motion, MovementParameters p) + { + if (!HasPhysicsObj) return WeenieError.NoPhysicsObject; + + float speed = p.Speed; + _interp.adjust_motion(ref motion, ref speed, p.HoldKeyToApply); + p.Speed = speed; + + return _interp.StopInterpretedMotion(motion, p); + } + + /// + /// Retail MoveToManager::CancelMoveTo (00529930, raw + /// 306886-306940). VERBATIM per decomp §7c: no-op when + /// is already Invalid (reentrancy guard — + /// see the class doc's reentrancy invariant). Otherwise: drain + /// , , then + /// StopCompletely. The argument is NEVER READ + /// in retail's body (every call site's error code is dropped in this + /// build, decomp §7c) — kept for parity/logging/tests only; NO behavior + /// depends on its value. + /// + public void CancelMoveTo(WeenieError error) + { + _ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging. + + if (MovementTypeState == MovementType.Invalid) return; + + _pendingActions.Clear(); + CleanUp(); + if (HasPhysicsObj) _stopCompletely(); + } + + /// + /// Retail MoveToManager::CleanUp (005295c0, raw + /// 306710-306736). VERBATIM per decomp §7d: build a local params + /// (hold_key copied from , CancelMoveTo cleared), + /// then if a physics_obj exists: stop the current command (if any), stop + /// the aux command (if any), and — if this was an object move + /// ( != 0 AND + /// != Invalid) — () + /// (the P4 TargetTracker unsubscribe). Finally + /// . Does NOT drain + /// / + /// do that first. + /// + public void CleanUp() + { + var localParams = new MovementParameters + { + HoldKeyToApply = Params.HoldKeyToApply, + CancelMoveTo = false, + }; + + if (HasPhysicsObj) + { + if (CurrentCommand != 0) _StopMotion(CurrentCommand, localParams); + if (AuxCommand != 0) _StopMotion(AuxCommand, localParams); + if (TopLevelObjectId != 0 && MovementTypeState != MovementType.Invalid) + { + _clearTarget(); + } + } + + InitializeLocalVariables(); + } + + /// + /// Retail MoveToManager::CleanUpAndCallWeenie (00529650, + /// raw 306740-306752). Despite the name, contains NO weenie call in this + /// build (decomp §7e — the compiled-out server-side callback; body ≡ + /// + StopCompletely). + /// is a documented CLIENT ADDITION firing HERE ONLY (see its doc) + /// standing in for ACE's server-side OnMoveComplete — do NOT + /// present it as retail behavior. + /// + public void CleanUpAndCallWeenie(WeenieError error) + { + CleanUp(); + if (HasPhysicsObj) _stopCompletely(); + MoveToComplete?.Invoke(error); + } + + // ── internal helper: the 10-field MovementParameters copy every entry + // point performs (§3b/§3c/§3d/§3e all do this identically) ────────── + + private void CopyParams(MovementParameters p) + { + Params.CanWalk = p.CanWalk; + Params.CanRun = p.CanRun; + Params.CanSidestep = p.CanSidestep; + Params.CanWalkBackwards = p.CanWalkBackwards; + Params.CanCharge = p.CanCharge; + Params.FailWalk = p.FailWalk; + Params.UseFinalHeading = p.UseFinalHeading; + Params.Sticky = p.Sticky; + Params.MoveAway = p.MoveAway; + Params.MoveTowards = p.MoveTowards; + Params.UseSpheres = p.UseSpheres; + Params.SetHoldKey = p.SetHoldKey; + Params.Autonomous = p.Autonomous; + Params.ModifyRawState = p.ModifyRawState; + Params.ModifyInterpretedState = p.ModifyInterpretedState; + Params.CancelMoveTo = p.CancelMoveTo; + Params.StopCompletelyFlag = p.StopCompletelyFlag; + Params.DisableJumpDuringLink = p.DisableJumpDuringLink; + + Params.DistanceToObject = p.DistanceToObject; + Params.MinDistance = p.MinDistance; + Params.DesiredHeading = p.DesiredHeading; + Params.Speed = p.Speed; + Params.FailDistance = p.FailDistance; + Params.WalkRunThreshhold = p.WalkRunThreshhold; + Params.ContextId = p.ContextId; + Params.HoldKeyToApply = p.HoldKeyToApply; + Params.ActionStamp = p.ActionStamp; + } +} + +/// +/// Retail TargetInfo (acclient.h:31591, struct #3482) — the callback +/// payload consumes. The P4 +/// TargetTracker adapter (App-side, R4-V4 scope) is the ONLY producer; +/// itself has no target-tracking machinery of its +/// own (TargetManager bodies are R5 — decomp §9f, do-not-invent list). +/// +/// Retail object_id — matched against +/// ; a mismatch is silently +/// ignored (decomp §6d). +/// Retail status — see . +/// Retail target_position — the raw +/// (possibly jittery) target position. +/// Retail interpolated_position — +/// the smoothed tracking point +/// steers toward. +public readonly record struct TargetInfo( + uint ObjectId, + TargetStatus Status, + Position TargetPosition, + Position InterpolatedPosition); + +/// +/// Retail TargetStatus (acclient.h:7264). Only vs +/// non- is behaviorally distinguished by +/// in this build (decomp §6d) +/// — the other values are carried for parity with the P4 TargetTracker +/// contract (V0-pins.md §P4). +/// +public enum TargetStatus +{ + /// 0 — undefined/uninitialized. + Undefined = 0, + /// 1 — target resolved and tracked normally. + Ok = 1, + /// 2 — target left the world (despawned). + ExitWorld = 2, + /// 3 — target teleported. + Teleported = 3, + /// 4 — target became contained (picked up / stowed). + Contained = 4, + /// 5 — target became parented (e.g. mounted/wielded). + Parented = 5, + /// 6 — tracker timed out without an update. + TimedOut = 6, +} diff --git a/src/AcDream.Core/Physics/Motion/MoveToNode.cs b/src/AcDream.Core/Physics/Motion/MoveToNode.cs new file mode 100644 index 00000000..06052c0f --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MoveToNode.cs @@ -0,0 +1,47 @@ +namespace AcDream.Core.Physics.Motion; + +/// +/// R4-V2 — verbatim port of retail's MoveToManager::MovementNode +/// (acclient.h:57702, struct #6350): +/// +/// struct __cppobj MoveToManager::MovementNode : DLListData +/// { // +0 dllist_next, +4 dllist_prev (DLListData) +/// MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued +/// float heading; // +0xc — only meaningful for type 9 +/// }; +/// +/// +/// +/// NAME WATCH (r4-port-plan.md §3 "New code target"): named +/// MoveToNode, NOT MovementNode, to avoid colliding with R2's +/// (the UNRELATED CMotionInterp::pending_motions +/// node — a different queue on a different class; see the AD-34 register +/// wording on both node types' shared "managed collection standing in for +/// retail's intrusive DLList/LList" pattern). +/// +/// +/// +/// Retail allocates these with operator new(0x10) and links them onto +/// MoveToManager::pending_actions (a DLList — doubly-linked, +/// unlike CMotionInterp's singly-linked LList) via +/// DLListBase::InsertAfter (tail-append; r4-moveto-decomp.md §4a). +/// Ported as a managed +/// of this value type — same pattern as 's port +/// (AD-34 wording): node identity semantics preserved via +/// LinkedListNode<MoveToNode> references rather than raw +/// prev/next pointers, FIFO order preserved via tail-append + +/// RemoveFirst. +/// +/// +/// Retail type (+8) — only +/// (7) and +/// (9) are ever queued +/// (r4-moveto-decomp.md §4a: AddMoveToPositionNode / +/// AddTurnToHeadingNode are the only two producers; +/// 's dispatch is a defensive +/// if/if, not a full switch — an unknown type stalls rather than +/// throwing, matching the raw's shape). +/// Retail heading (+0xc) — only meaningful for +/// nodes; zero/unused for +/// nodes. +public readonly record struct MoveToNode(MovementType Type, float Heading); diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs new file mode 100644 index 00000000..7d9fcc0a --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs @@ -0,0 +1,295 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — HandleMoveToPosition Phase 2 (00529d80, raw +/// 307283-307331) and CheckProgressMade (005290f0, raw +/// 306385-306431), per r4-moveto-decomp.md §6b/§5b: arrival predicate +/// (chase dist <= DistanceToObject vs flee +/// dist >= MinDistance), fail-distance ( +/// 0x3D), and the 1-second / 0.25-units-per-second progress window (BOTH +/// incremental AND overall rates). +/// +public sealed class MoveToManagerArrivalAndProgressTests +{ + // ── CheckProgressMade — the progress-clock table (§5b) ───────────────── + + [Fact] + public void CheckProgressMade_WithinOneSecondWindow_AlwaysTrue_TooSoonToJudge() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); + + h.Advance(0.5); // < 1.0s since PreviousDistanceTime + + Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f)); + } + + [Fact] + public void CheckProgressMade_ExactlyOneSecond_StillTooSoon_Inclusive() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); + + h.Advance(1.0); // elapsed <= 1.0 -> still true (§5b: "elapsed <= 1.0 -> return 1") + + Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f)); + } + + [Fact] + public void CheckProgressMade_AfterWindow_SufficientIncrementalAndOverallRate_True() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); + + // PreviousDistance/OriginalDistance seeded to 20 at t=0. + h.Advance(2.0); // 2s elapsed + + // Closed 1 unit/s over 2s = 2 units closed -> rate 1.0 >= 0.25 both ways. + bool progress = h.Manager.CheckProgressMade(currentDistance: 18f); + + Assert.True(progress); + Assert.Equal(0u, h.Manager.FailProgressCount); + Assert.Equal(18f, h.Manager.PreviousDistance, 2); // incremental checkpoint advanced + } + + [Fact] + public void CheckProgressMade_InsufficientIncrementalRate_False_CheckpointDoesNotAdvance() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); + + h.Advance(2.0); + + // Closed only 0.1 unit over 2s -> rate 0.05 < 0.25 -> no progress; + // checkpoint (PreviousDistance) must NOT advance. + bool progress = h.Manager.CheckProgressMade(currentDistance: 19.9f); + + Assert.False(progress); + Assert.Equal(20f, h.Manager.PreviousDistance, 2); // unchanged + } + + [Fact] + public void CheckProgressMade_GoodIncrementalButBadOverallRate_False() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); + + // OriginalDistance/OriginalDistanceTime were seeded to (20, t=0) by + // BeginMoveForward and NEVER move again except on arrival/retarget — + // only PreviousDistance (the incremental checkpoint) advances on a + // passing tick. Simulate a long slow crawl: many small incremental + // passes that each individually clear 0.25/s over their own 1s+ + // window, but the OVERALL rate since t=0 stays under 0.25/s because + // the total elapsed time dominates. + h.Advance(2.0); + Assert.True(h.Manager.CheckProgressMade(19f)); // incremental 0.5/s since t=0 — overall also 0.5/s here, still passes. + + // Now advance a huge amount of time with only a tiny further close — + // incremental since the LAST checkpoint (t=2, dist=19) is healthy + // relative to its own short window, but overall since t=0 (dist 20) + // over the huge elapsed time is far under 0.25/s. + h.Advance(200.0); + bool progress = h.Manager.CheckProgressMade(18.9f); // incremental: 0.1/200s -> fails incremental too in this construction; assert false either way (both gates must independently pass). + + Assert.False(progress); + } + + [Fact] + public void CheckProgressMade_MovingAway_UsesOpeningDistance() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + // pure-away move: MoveTowards=false, MoveAway=true, MinDistance large + // so get_command picks WalkForward+movingAway. + var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 50f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + Assert.True(h.Manager.MovingAway); + + h.Advance(2.0); + // Distance to the (now-behind) target GREW from ~20 to 25 -> opening + // at 2.5/s -> progress (moving_away: progress = curr - previous). + bool progress = h.Manager.CheckProgressMade(25f); + + Assert.True(progress); + } + + // ── Arrival predicate (§6b Phase 2) — chase vs flee ──────────────────── + + [Fact] + public void HandleMoveToPosition_Chase_ArrivesWhenDistLessOrEqualDto() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 5f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + // Walk the mover to within DistanceToObject and let enough time pass + // for CheckProgressMade to evaluate true. + h.WorldPosition = new Position(1u, new Vector3(16f, 0f, 0f), Quaternion.Identity); // dist=4 <= dto(5) + h.Advance(2.0); + + h.Manager.HandleMoveToPosition(); + + // Arrival -> node popped, CurrentCommand cleared, BeginNextNode ran + // (queue now empty, non-sticky) -> CleanUp + StopCompletely -> + // MovementTypeState back to Invalid. + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void HandleMoveToPosition_Chase_NotArrivedYet_StaysActive() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + h.WorldPosition = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity); // dist=15, still far + h.Advance(2.0); + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); + } + + [Fact] + public void HandleMoveToPosition_Flee_ArrivesWhenDistGreaterOrEqualMinDistance() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 10f, UseSpheres = false }; + // Start close (dist=5 < MinDistance=10) so get_command picks + // WalkForward+movingAway (pure-away band, §5c). + h.Manager.MoveToPosition(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), p); + Assert.True(h.Manager.MovingAway); + h.DrainPendingMotions(); + + // Mover has fled to distance 12 (>= MinDistance 10) -> arrived. + h.WorldPosition = new Position(1u, new Vector3(-7f, 0f, 0f), Quaternion.Identity); // dist to (5,0,0) = 12 + h.Advance(2.0); + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + // ── Fail-distance (§6b, WeenieError.YouChargedTooFar) ────────────────── + + [Fact] + public void HandleMoveToPosition_ProgressMadeButOverFailDistance_CancelsAsYouChargedTooFar() + { + // §6b Phase 2 ordering: the fail_distance check lives INSIDE the + // "CheckProgressMade == true, but not yet arrived" branch — a + // no-progress tick never reaches it at all (that tick only + // increments FailProgressCount). So the fail-distance trigger + // requires: progress WAS made (rate >= 0.25 both ways) toward the + // target, arrival not yet reached, AND total displacement from + // StartingPosition exceeds FailDistance — e.g. the mover overshot + // past the target along a path that looped far from the start. + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 5f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + // Mover advanced to (8,0,0): 12 units closed toward the target over + // 2s (rate 6/s, passes both incremental+overall) but has traveled + // 8 units from StartingPosition(0,0,0) — over FailDistance(5) — + // while still 12 units short of arrival (dto=0.6). + h.WorldPosition = new Position(1u, new Vector3(8f, 0f, 0f), Quaternion.Identity); + h.Advance(2.0); + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void HandleMoveToPosition_NoProgressButWithinFailDistance_StaysActive_NoCancel() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 100f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + h.WorldPosition = new Position(1u, new Vector3(0f, 1f, 0f), Quaternion.Identity); // 1 unit from start, well under FailDistance + h.Advance(2.0); + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); + } + + // ── FailProgressCount write-only bookkeeping (§8, do-not-invent) ─────── + + [Fact] + public void FailProgressCount_IncrementsOnStall_ButNoGiveUpThresholdExists() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + // Stall many times (no progress, not interpolating, not animating) — + // FailProgressCount should climb with NO cap and NO resulting + // cancellation, since the counter is write-only in retail (§8). + for (int i = 0; i < 20; i++) + { + h.Advance(2.0); + h.Manager.HandleMoveToPosition(); + } + + Assert.True(h.Manager.FailProgressCount >= 20); + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); // still active, no give-up + } + + [Fact] + public void FailProgressCount_NotIncremented_WhenInterpolating() + { + var h = new MoveToManagerHarness { IsInterpolatingValue = true }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + h.DrainPendingMotions(); + + h.Advance(2.0); + h.Manager.HandleMoveToPosition(); + + Assert.Equal(0u, h.Manager.FailProgressCount); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs new file mode 100644 index 00000000..2fadd0f8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs @@ -0,0 +1,147 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — HandleMoveToPosition Phase 1 aux-turn steering +/// (00529d80, raw 307187-307280) per r4-moveto-decomp.md §6b: the +/// 20°/340° deadband, direction pick (diff ≥ 180 → TurnLeft else TurnRight), +/// no-redundant-reissue, and the "stop aux while animating" branch. +/// +public sealed class MoveToManagerAuxTurnTests +{ + /// Drives a manager into an active MoveToPosition (heading + /// already settled so BeginMoveForward runs on the first BeginNextNode), + /// then drains the interp's pending_motions queue via a synthetic + /// MotionDone callback — standing in for "the WalkForward/RunForward + /// animation-table dispatch cycle has started/completed" the way a real + /// AnimationSequencer would signal it. Without this, MotionsPending() + /// stays true forever (BeginMoveForward's own _DoMotion dispatch + /// enqueues a node that nothing else in this bare harness ever pops), + /// and HandleMoveToPosition's Phase 1 would perpetually take the + /// "animating, stop aux" branch — never exercising the deadband/turn + /// logic this test file targets. + private static MoveToManagerHarness ArmMoving(float initialHeading, Vector3 targetXY) + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = initialHeading; + + var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, targetXY, Quaternion.Identity), p); + + h.DrainPendingMotions(); + + return h; + } + + [Fact] + public void WithinDeadband_NoAuxTurnIssued() + { + // Target due east (heading 90); mover already facing 85 -> diff 5, + // inside [0,20] deadband. + var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); + h.Heading = 85f; // simulate drift after the initial turn-to-face completed + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(0u, h.Manager.AuxCommand); + } + + [Fact] + public void JustOutsideDeadband_Positive_IssuesTurnRight() + { + var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); + h.Heading = 40f; // diff = 90-40 = 50 -> outside [0,20]∪[340,360) + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); + } + + [Fact] + public void DiffAtOrAbove180_IssuesTurnLeft() + { + var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); + h.Heading = 300f; // diff = 90-300 = -210 -> +360 = 150... need >=180 for TurnLeft; pick 260. + h.Heading = 260f; // diff = 90-260=-170 -> +360=190 (>=180) -> TurnLeft + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MotionCommand.TurnLeft, h.Manager.AuxCommand); + } + + [Fact] + public void DeadbandUpperBoundary_340_NoTurn() + { + var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0 + h.Heading = 20f; // diff = 0-20=-20 -> +360=340 -> boundary INCLUSIVE (diff >= 340) + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(0u, h.Manager.AuxCommand); + } + + [Fact] + public void DeadbandLowerBoundary_20_NoTurn() + { + var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0 + h.Heading = -20f % 360f; // normalize below + h.Heading = 340f; // diff = 0-340 = -340 -> +360=20 -> boundary INCLUSIVE (diff <= 20) + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(0u, h.Manager.AuxCommand); + } + + [Fact] + public void NoRedundantReissue_SameDirectionTwice_DoesNotRedispatch() + { + var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); + h.Heading = 40f; // outside deadband -> TurnRight + + h.Manager.HandleMoveToPosition(); + uint firstAux = h.Manager.AuxCommand; + Assert.Equal(MotionCommand.TurnRight, firstAux); + + // Drain the interp's pending_motions queue (the TurnRight dispatch + // just enqueued a node) so Phase 1's "not animating" branch runs + // again on the second tick — otherwise MotionsPending() would stay + // true and Phase 1 would take the "animating, stop aux" branch + // instead of exercising the redundant-reissue guard this test + // targets. + h.DrainPendingMotions(); + + int stopCallsBefore = h.StopCompletelyCalls; // unrelated counter, just for isolation + + // Second tick, still outside deadband, same direction -> _DoMotion + // should NOT be re-issued (turn != AuxCommand test fails since + // AuxCommand already equals TurnRight) — assert AuxCommand is + // unchanged (still TurnRight) as the observable proxy. + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); + Assert.Equal(stopCallsBefore, h.StopCompletelyCalls); + } + + [Fact] + public void AnimatingMotionsPending_StopsAuxTurn_DoesNotStartNew() + { + var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); + h.Heading = 40f; + h.Manager.HandleMoveToPosition(); + Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); + + // Simulate an animation-table motion still pending by queueing a + // node onto the REAL MotionInterpreter's pending_motions. + h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0); + Assert.True(h.Interp.MotionsPending()); + + h.Manager.HandleMoveToPosition(); + + Assert.Equal(0u, h.Manager.AuxCommand); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs new file mode 100644 index 00000000..9535c870 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs @@ -0,0 +1,157 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — MoveToManager::BeginMoveForward (00529a00, raw +/// 306957-307042) per r4-moveto-decomp.md §4c: dispatched motion id / hold +/// key, the write-back to the STORED params, and the progress-clock seed. +/// Also exercises the run→walk demote inside WalkRunThreshhold (the +/// R3 visual-pass expected-diff this closes). +/// +public sealed class MoveToManagerBeginMoveForwardTests +{ + [Fact] + public void FarFromTarget_CanRunCanWalk_DispatchesRunForward() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; // already facing target — no aux turn needed + + var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f }; + // Distance far beyond threshold+dto -> Run. + h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); + + // MoveToPosition's node plan queues [TurnToHeading(face)] first since + // heading(0->target)=90 != current heading is not tested here (we + // set Heading=90 already so diff=0, GetCommand still picks motion + // because distance is huge, so a turn node is queued anyway — but + // since diff==0 the queued turn will complete immediately in + // BeginNextNode's synchronous dispatch, landing directly on + // BeginMoveForward). + // ForwardCommand (post-adjust_motion, dispatched to the interp) is + // RunForward; CurrentCommand (the manager's OWN field) stores the + // PRE-adjust command GetCommand chose — get_command's own body only + // ever returns WalkForward/WalkBackward/0 (§5c) — the Run promotion + // happens downstream, inside adjust_motion (_DoMotion §7a), and is + // never written back into CurrentCommand. + Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); + Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); + Assert.Equal(MotionCommand.WalkForward, h.Manager.CurrentCommand); + } + + [Fact] + public void WithinWalkRunThreshold_DemotesToWalk() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f }; + // dist - dto = 10 - 0.6 = 9.4 <= 15 -> walk. + h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p); + + Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); + Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply); + } + + [Fact] + public void CanCharge_FastPathWins_RunsEvenWhenClose() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, CanCharge = true }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity), p); + + Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); + Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); + } + + [Fact] + public void HoldKeyWriteBack_ToStoredParams_NotJustLocalCopy() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, HoldKeyToApply = HoldKey.Invalid }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); + + Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); + } + + [Fact] + public void ProgressClockSeeded_PreviousAndOriginalEqualCurrentDistance() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.CurTime = 5.0; + + // UseSpheres defaults true on a fresh MovementParameters, and + // MoveToPosition's own params (copied into Params BEFORE + // BeginMoveForward runs) drive GetCurrentDistance's use_spheres + // branch: cylinder distance = center distance - ownRadius(0.5) - + // targetRadius(0, position moves always zero SoughtObjectRadius). + var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + + Assert.Equal(20f, h.Manager.PreviousDistance, 2); + Assert.Equal(20f, h.Manager.OriginalDistance, 2); + Assert.Equal(5.0, h.Manager.PreviousDistanceTime, 3); + Assert.Equal(5.0, h.Manager.OriginalDistanceTime, 3); + } + + [Fact] + public void ProgressClockSeeded_UseSpheresDefault_UsesCylinderDistance() + { + var h = new MoveToManagerHarness { OwnRadius = 0.5f }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.CurTime = 5.0; + + var p = new MovementParameters { DistanceToObject = 0.6f }; // UseSpheres=true (default) + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); + + // center distance 20 - ownRadius 0.5 - targetRadius 0 (position + // moves zero SoughtObjectRadius, §3c) = 19.5. + Assert.Equal(19.5f, h.Manager.PreviousDistance, 2); + Assert.Equal(19.5f, h.Manager.OriginalDistance, 2); + } + + [Fact] + public void CancelMoveToBit_ClearedOnLocalParams_DoesNotSelfCancel() + { + // If the 0x8000 CancelMoveTo bit were NOT cleared on the local + // params passed into _DoMotion, InterruptCurrentMovement-style + // cancellation logic downstream could tear down THIS moveto before + // it starts. We assert the observable effect: the manager is still + // MovingTo after BeginMoveForward dispatches. + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); + + Assert.True(h.Manager.IsMovingTo()); + } + + [Fact] + public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode() + { + 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.True(h.Manager.IsMovingTo()); + + h.Manager.HasPhysicsObj = false; + h.Manager.BeginMoveForward(); + + Assert.False(h.Manager.IsMovingTo()); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs new file mode 100644 index 00000000..59647fb7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs @@ -0,0 +1,154 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — scripted end-to-end table drive (r4-port-plan.md §3 V2 test +/// list): positions fed per tick -> expected node pops + dispatched motion +/// ids/hold keys, including the run-to-walk demote as the mover closes to +/// within WalkRunThreshhold of the target — the exact R3 visual-pass +/// expected-diff this closes ("auto-walk-at-run walk-pace legs (R4)"). +/// +public sealed class MoveToManagerEndToEndTableDriveTests +{ + [Fact] + public void ChaseSequence_TurnFirst_ThenRun_ThenDemoteToWalk_ThenArrive() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; // facing NORTH; target is due EAST -> a turn-to-face node is required first. + + var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); + + // Step 1: node plan = [TurnToHeading(90), MoveToPosition]. Heading + // diff (0->90) is large -> BeginTurnToHeading armed a real turn + // (not the "already there" early-out). + Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions)); + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + h.DrainPendingMotions(); + + // Step 2: the turn completes (heading snaps to 90 via + // HandleTurnToHeading's arrival branch) -> BeginNextNode pops to the + // MoveToPosition node -> BeginMoveForward dispatches. Far from the + // target (100 units, minus threshold 15 well exceeded) -> RunForward. + h.Heading = 91f; // "passed" 90 + h.Manager.HandleTurnToHeading(); + h.DrainPendingMotions(); + + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); + Assert.Single(h.Manager.PendingActions); + Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); + Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); + + // Step 3: close the distance to just inside the walk/run threshold. + // Phase 2 of HandleMoveToPosition doesn't re-run get_command; only + // BeginMoveForward does (dispatched once per node, on arm) — so the + // walk-demote re-evaluation requires a fresh moveto issue. Route it + // through PerformMovement (NOT a direct MoveToPosition call) — this + // is the retail-faithful re-issue shape (§3a: cancel current + + // unstick FIRST, THEN dispatch) and avoids stacking a stale node + // onto the still-populated queue the way a bare second + // MoveToPosition call would (MoveToPosition itself does not drain; + // only PerformMovement's CancelMoveTo call does). + h.WorldPosition = new Position(1u, new Vector3(90f, 0f, 0f), Quaternion.Identity); // 10 units from target + h.Heading = 90f; // already facing it + var pClose = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false }; + h.Manager.PerformMovement(new MovementStruct + { + Type = MovementType.MoveToPosition, + Pos = new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), + Params = pClose, + }); + // PerformMovement's CancelMoveTo (§3a) stops the in-flight motion + // FIRST, which itself enqueues a pending_motions node -- so + // BeginTurnToHeading's wait-for-anims gate (§4d) defers the "already + // facing it" early-out to the NEXT drain, not synchronously inside + // this call. Drain twice: once for the cancel's own stop dispatch, + // once more for whatever BeginTurnToHeading/BeginMoveForward issues + // once armed. + h.DrainPendingMotions(); + h.Manager.BeginNextNode(); // re-check the head node now that anims have drained + h.DrainPendingMotions(); + + Assert.Single(h.Manager.PendingActions); // the stale queue was drained by PerformMovement's CancelMoveTo; the "already facing it" turn completed instantly once anims cleared. + + // dist=10, dto=0.6 -> dist-dto=9.4 <= 15 -> WALK. + Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); + Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply); + + // Step 4: arrive. + h.WorldPosition = new Position(1u, new Vector3(99.7f, 0f, 0f), Quaternion.Identity); + h.Advance(2.0); + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Empty(h.Manager.PendingActions); + } + + [Fact] + public void FleeSequence_WalksBackward_InsideMinBand_ArrivesWhenFarEnough() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity); + h.Heading = 270f; // facing the threat (target) which is behind at origin -- WalkBackward needs no turn. + + // towards_and_away band: dist(3) inside [MinDistance(5)... wait need + // dist < min for the inside-band WalkBackward pick]. Use MinDistance + // 5 with mover at distance 3 from the target (origin) -> inside + // band -> WalkBackward, no turn node queued (§5d asymmetry). + var p = new MovementParameters { MoveTowards = true, MoveAway = true, MinDistance = 5f, DistanceToObject = 8f, UseSpheres = false }; + h.Manager.MoveToPosition(new Position(1u, Vector3.Zero, Quaternion.Identity), p); + + Assert.True(h.Manager.MovingAway); + Assert.Equal(MotionCommand.WalkBackward, h.Manager.CurrentCommand); // pre-adjust id (get_command's own return) + // adjust_motion normalizes WalkBackward -> WalkForward with a + // negative BackwardsFactor-scaled speed, dispatched as ForwardCommand. + Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); + Assert.True(h.ForwardSpeed < 0f); + h.DrainPendingMotions(); + + // Flee to distance 6 (>= MinDistance 5) -> arrived. + h.WorldPosition = new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity); + h.Advance(2.0); + h.Manager.HandleMoveToPosition(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void TurnToObjectSequence_DeferredStart_ThenRetargetIgnored_ThenArrivesOnHeadingPass() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + + const uint targetId = 0x5000CCCCu; + h.Manager.TurnToObject(targetId, targetId, new MovementParameters()); + Assert.Empty(h.Manager.PendingActions); // deferred (§3d) + + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 + h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target, target)); + + Assert.True(h.Manager.Initialized); + Assert.Single(h.Manager.PendingActions); + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + h.DrainPendingMotions(); + + // Retarget while running: TurnToObject gets no handling (heading frozen). + var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 + h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target2, target2)); + var soughtBefore = h.Manager.SoughtPosition; + Assert.Equal(soughtBefore, h.Manager.SoughtPosition); // sanity: unchanged by its own read + + // Complete the turn toward the ORIGINAL (frozen) heading (90), not target2's. + h.Heading = 91f; + h.Manager.HandleTurnToHeading(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Equal(90f, h.Heading, 2); // snapped to the frozen heading, unaffected by the retarget. + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs new file mode 100644 index 00000000..255c77fd --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs @@ -0,0 +1,158 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — HandleUpdateTarget (0052a7d0, raw 307802-307867, +/// decomp §6d): the P4 TargetTracker feed's deferred-start lifecycle +/// (Initialized=false = the first callback vs true = an in-flight retarget), +/// context/target-id filtering, self-target instant success, NoObject vs +/// ObjectGone status handling, and the retarget progress-clock reset. +/// +public sealed class MoveToManagerHandleUpdateTargetTests +{ + private const uint TargetId = 0x50004444u; + + private static MoveToManagerHarness ArmMoveToObject(float ownRadius = 0.5f, float ownHeight = 2f) + { + var h = new MoveToManagerHarness { OwnRadius = ownRadius, OwnHeight = ownHeight }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + h.Manager.MoveToObject(TargetId, TargetId, radius: 1f, height: 2f, new MovementParameters()); + return h; + } + + [Fact] + public void IgnoresUpdate_ForADifferentTarget() + { + var h = ArmMoveToObject(); + var wrongTargetPos = new Position(1u, new Vector3(5f, 5f, 0f), Quaternion.Identity); + + h.Manager.HandleUpdateTarget(new TargetInfo(0x59999999u, TargetStatus.Ok, wrongTargetPos, wrongTargetPos)); + + Assert.False(h.Manager.Initialized); + Assert.Empty(h.Manager.PendingActions); + } + + [Fact] + public void FirstCallback_NonOkStatus_CancelsAsNoObject() + { + var h = ArmMoveToObject(); + var pos = new Position(1u, Vector3.Zero, Quaternion.Identity); + + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, pos, pos)); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void FirstCallback_OkStatus_BuildsNodePlan_SetsInitialized() + { + var h = ArmMoveToObject(); + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); + + Assert.True(h.Manager.Initialized); + Assert.NotEmpty(h.Manager.PendingActions); + } + + [Fact] + public void FirstCallback_OrdinaryTarget_DoesNotFireMoveToComplete() + { + // MoveToObject's OWN self-target branch (§3b) already short-circuits + // via CleanUp+StopCompletely BEFORE any HandleUpdateTarget ever + // fires for a same-id target — so HandleUpdateTarget's self-target + // instant-success path (§6d: "top_level_object_id == + // physics_obj->id") is reachable only in the deferred-start window, + // and is covered by construction in + // MoveToManagerNodePlanTests.MoveToObject_SelfTarget_* + // (MoveToObject never even reaches SetTarget for a self-id, so the + // callback path is dead in practice — retail's redundant guard). + // This test isolates the ORDINARY path: MoveToComplete's only + // trigger is CleanUpAndCallWeenie, never a plain node-plan build. + var h = ArmMoveToObject(); + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); + + Assert.Empty(h.MoveToCompleteCalls); + } + + [Fact] + public void Retarget_NonOkStatus_CancelsAsObjectGone() + { + var h = ArmMoveToObject(); + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); + Assert.True(h.Manager.Initialized); + + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, target, target)); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void Retarget_UpdatesPositions_ResetsProgressClock_DoesNotRequeueNodes() + { + var h = ArmMoveToObject(); + var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target1, target1)); + int nodeCountAfterFirst = System.Linq.Enumerable.Count(h.Manager.PendingActions); + Assert.True(nodeCountAfterFirst > 0); + + h.Advance(3.0); // simulate time passing, progress clock advanced by BeginMoveForward + + var target2 = new Position(1u, new Vector3(20f, 5f, 0f), Quaternion.Identity); + var interp2 = new Position(1u, new Vector3(19f, 5f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, interp2)); + + Assert.Equal(target2, h.Manager.CurrentTargetPosition); + Assert.Equal(interp2, h.Manager.SoughtPosition); + Assert.Equal(float.MaxValue, h.Manager.PreviousDistance); + Assert.Equal(float.MaxValue, h.Manager.OriginalDistance); + + // Node count unchanged by the retarget itself (no requeue) — the + // running MoveToPosition node keeps steering toward the moved + // CurrentTargetPosition on its own next tick. + Assert.Equal(nodeCountAfterFirst, System.Linq.Enumerable.Count(h.Manager.PendingActions)); + } + + [Fact] + public void Retarget_TurnToObject_GetsNoRetargetHandling_HeadingFrozen() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + h.Manager.TurnToObject(TargetId, TargetId, new MovementParameters()); + + var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 + h.Manager.TurnToObject_Internal(target1); // first callback (direct, mirrors deferred-start call shape) + Assert.True(h.Manager.Initialized); + var soughtAfterFirst = h.Manager.SoughtPosition; + + // A retarget-shaped HandleUpdateTarget call for a TurnToObject + // manager: since Initialized is already true, this takes the + // "retarget" branch, which only updates state for MoveToObject — + // TurnToObject gets NO handling at all (decomp §6d note). + var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, target2)); + + Assert.Equal(soughtAfterFirst, h.Manager.SoughtPosition); // untouched + } + + [Fact] + public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode() + { + var h = ArmMoveToObject(); + h.Manager.HasPhysicsObj = false; + + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); + + Assert.False(h.Manager.IsMovingTo()); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs new file mode 100644 index 00000000..fa4527e6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs @@ -0,0 +1,100 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — MoveToManager construction / InitializeLocalVariables +/// (00529250, raw 306490-306534) / Destroy (005294b0) / +/// is_moving_to (00529220). Per r4-moveto-decomp.md §1: the ctor +/// zeroes the FLAGS WORD + context_id only (NOT ACE's A2/A3 full-struct-reset +/// transposition — scalar param fields keep stale values since every entry +/// point copies all ten fields anyway), resets both progress-clock pairs to +/// FLT_MAX/now, and resets Sought/CurrentTarget positions but NOT +/// StartingPosition. +/// +public sealed class MoveToManagerLifecycleTests +{ + [Fact] + public void FreshManager_MovementTypeIsInvalid() + { + var h = new MoveToManagerHarness(); + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.False(h.Manager.IsMovingTo()); + } + + [Fact] + public void FreshManager_ProgressClocksAreFltMax() + { + var h = new MoveToManagerHarness(); + Assert.Equal(float.MaxValue, h.Manager.PreviousDistance); + Assert.Equal(float.MaxValue, h.Manager.OriginalDistance); + } + + [Fact] + public void FreshManager_BitfieldFlagsAllClear_ScalarsUntouchedByCtorReset() + { + // InitializeLocalVariables clears ONLY the bitfield + context_id. + // The scalar fields (DistanceToObject etc.) are NOT part of that + // clear — but since Params starts as `new MovementParameters()` + // (retail ctor defaults), the scalars already hold their defaults + // here; the "stale values survive InitializeLocalVariables" claim is + // exercised by MoveToManagerCancelAndCleanupTests (a scalar surviving + // a CleanUp/InitializeLocalVariables round-trip after being changed + // by an entry point). + var h = new MoveToManagerHarness(); + Assert.False(h.Manager.Params.CanWalk); + Assert.False(h.Manager.Params.CanRun); + Assert.False(h.Manager.Params.MoveTowards); + Assert.False(h.Manager.Params.CancelMoveTo); + Assert.Equal(0u, h.Manager.Params.ContextId); + } + + [Fact] + public void FreshManager_SoughtPositionAndCurrentTargetAreIdentityFrameAtCellZero() + { + // NOT default(Position) — default(Quaternion) is the ZERO + // quaternion, not identity. Retail resets to a genuine identity + // frame (decomp §1c) at cell id 0. + var h = new MoveToManagerHarness(); + var expected = new Position(0u, System.Numerics.Vector3.Zero, System.Numerics.Quaternion.Identity); + Assert.Equal(expected, h.Manager.SoughtPosition); + Assert.Equal(expected, h.Manager.CurrentTargetPosition); + Assert.Equal(0f, MoveToMath.GetHeading(h.Manager.SoughtPosition.Frame.Orientation)); + } + + [Fact] + public void FreshManager_PendingActionsEmpty() + { + var h = new MoveToManagerHarness(); + Assert.Empty(h.Manager.PendingActions); + } + + [Fact] + public void Destroy_DrainsPendingActions_ThenReInitializes() + { + var h = new MoveToManagerHarness(); + h.Manager.AddMoveToPositionNode(); + h.Manager.AddTurnToHeadingNode(90f); + Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions)); + + h.Manager.Destroy(); + + Assert.Empty(h.Manager.PendingActions); + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void IsMovingTo_TrueAfterMoveToPosition_FalseAfterCancel() + { + var h = new MoveToManagerHarness(); + h.Manager.MoveToPosition(new Position(1u, new System.Numerics.Vector3(10f, 0f, 0f), System.Numerics.Quaternion.Identity), new MovementParameters()); + + Assert.True(h.Manager.IsMovingTo()); + + h.Manager.CancelMoveTo(WeenieError.ActionCancelled); + + Assert.False(h.Manager.IsMovingTo()); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs new file mode 100644 index 00000000..53f1af49 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs @@ -0,0 +1,306 @@ +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — node-plan goldens for each movement type. Per r4-moveto-decomp.md +/// §3c (MoveToPosition), §6f (MoveToObject_Internal), §3e (TurnToHeading), +/// §6g (TurnToObject_Internal): the SHAPE of pending_actions right +/// after the entry point (deferred moves) or the internal builder (object +/// moves, first target callback) runs. +/// +public sealed class MoveToManagerNodePlanTests +{ + // ── MoveToPosition (§3c): [TurnToHeading(face)] → [MoveToPosition] ──── + + [Fact] + public void MoveToPosition_NeedsMotion_QueuesTurnThenMove() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + + // Target due east (+X) -> heading 90. Far enough that get_command + // says motion is needed (default DistanceToObject=0.6). + h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), new MovementParameters()); + + var nodes = h.Manager.PendingActions.ToList(); + Assert.Equal(2, nodes.Count); + Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); + Assert.Equal(90f, nodes[0].Heading, 2); + Assert.Equal(MovementType.MoveToPosition, nodes[1].Type); + } + + [Fact] + public void MoveToPosition_AlreadyInRange_NoMotionNodesQueued() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + + // Target within default DistanceToObject (0.6) -> get_command idles. + h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), new MovementParameters()); + + Assert.Empty(h.Manager.PendingActions); + } + + [Fact] + public void MoveToPosition_UseFinalHeading_AppendsAbsoluteFinalTurnNode() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + + var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 270f }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p); + + var nodes = h.Manager.PendingActions.ToList(); + // Turn-to-face(90) -> MoveToPosition -> Turn-to-final(270, ABSOLUTE). + Assert.Equal(3, nodes.Count); + Assert.Equal(MovementType.TurnToHeading, nodes[2].Type); + Assert.Equal(270f, nodes[2].Heading, 2); + } + + [Fact] + public void MoveToPosition_UseFinalHeadingOnly_NoMotionNeeded_QueuesOnlyFinalTurn() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + + var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 45f }; + // Already in range -> no move/turn-to-face nodes, only the final turn. + h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); + + var nodes = h.Manager.PendingActions.ToList(); + Assert.Single(nodes); + Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); + Assert.Equal(45f, nodes[0].Heading, 2); + } + + [Fact] + public void MoveToPosition_ClearsStickyBit_EvenIfArgumentRequestedIt() + { + var h = new MoveToManagerHarness(); + var p = new MovementParameters { Sticky = true }; + h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); + + Assert.False(h.Manager.Params.Sticky); + } + + [Fact] + public void MoveToPosition_MovementTypeAndStartingPositionSet() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(2u, new Vector3(1f, 2f, 3f), Quaternion.Identity); + + // Distance 10 (> default DistanceToObject 0.6) so the move plan + // actually queues motion and MovementTypeState stays MoveToPosition + // instead of completing instantly via the empty-queue BeginNextNode + // path (see MoveToPosition_AlreadyInRange_NoMotionNodesQueued for + // that degenerate case). + h.Manager.MoveToPosition(new Position(2u, new Vector3(1f, 12f, 3f), Quaternion.Identity), new MovementParameters()); + + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); + Assert.Equal(h.WorldPosition, h.Manager.StartingPosition); + } + + // ── TurnToHeading (§3e): ONE node, immediate BeginNextNode ───────────── + + [Fact] + public void TurnToHeading_QueuesExactlyOneNode_WithDesiredHeading() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 123f }); + + var nodes = h.Manager.PendingActions.ToList(); + // BeginNextNode ran immediately and BeginTurnToHeading may have + // already popped the node if heading matched (it won't here — 123 + // != 0), so the node should still be present as "in flight" (its + // dispatch doesn't remove it — only arrival does). We assert via + // CurrentCommand instead of raw queue count, since BeginNextNode + // does run synchronously. + Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState); + Assert.NotEqual(0u, h.Manager.CurrentCommand); + Assert.Single(nodes); + Assert.Equal(123f, nodes[0].Heading, 2); + } + + [Fact] + public void TurnToHeading_ClearsStickyBit() + { + var h = new MoveToManagerHarness(); + h.Manager.TurnToHeading(new MovementParameters { Sticky = true, DesiredHeading = 45f }); + Assert.False(h.Manager.Params.Sticky); + } + + [Fact] + public void TurnToHeading_AlreadyFacingTarget_BeginNextNodeCompletesImmediately() + { + var h = new MoveToManagerHarness(); + h.Heading = 90f; + + // DesiredHeading == current heading -> BeginTurnToHeading's + // "already there" branch pops + BeginNextNode -> empty queue, + // non-sticky -> CleanUp + StopCompletely -> back to Invalid. + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Empty(h.Manager.PendingActions); + Assert.True(h.StopCompletelyCalls >= 1); + } + + // ── MoveToObject deferred start (§3b + §6f via HandleUpdateTarget) ───── + + [Fact] + public void MoveToObject_NoNodesQueuedUntilTargetCallback() + { + var h = new MoveToManagerHarness(); + h.Manager.MoveToObject(objectId: 0x50002222u, topLevelId: 0x50002222u, radius: 1f, height: 2f, new MovementParameters()); + + Assert.Empty(h.Manager.PendingActions); + Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState); + Assert.False(h.Manager.Initialized); + Assert.Single(h.SetTargetCalls); + Assert.Equal((0u, 0x50002222u, 0.5f, 0.0), h.SetTargetCalls[0]); + } + + [Fact] + public void MoveToObject_PreservesStickyBit_UnlikePositionMoves() + { + var h = new MoveToManagerHarness(); + var p = new MovementParameters { Sticky = true }; + h.Manager.MoveToObject(0x50002222u, 0x50002222u, 1f, 2f, p); + + Assert.True(h.Manager.Params.Sticky); + } + + [Fact] + public void MoveToObject_FirstTargetCallback_BuildsNodePlanViaInternal() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + + h.Manager.MoveToObject(0x50002222u, 0x50002222u, radius: 0.5f, height: 2f, new MovementParameters()); + + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target)); + + Assert.True(h.Manager.Initialized); + var nodes = h.Manager.PendingActions.ToList(); + Assert.Equal(2, nodes.Count); + Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); + Assert.Equal(90f, nodes[0].Heading, 2); + Assert.Equal(MovementType.MoveToPosition, nodes[1].Type); + } + + [Fact] + public void MoveToObject_UseFinalHeading_RelativeToInterpolatedHeading() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + + var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 10f }; + h.Manager.MoveToObject(0x50002222u, 0x50002222u, 0.5f, 2f, p); + + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 + h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target)); + + var nodes = h.Manager.PendingActions.ToList(); + Assert.Equal(3, nodes.Count); + // RELATIVE: iHeading(90) + desired(10) = 100 -- unlike MoveToPosition's absolute form. + Assert.Equal(100f, nodes[2].Heading, 2); + } + + [Fact] + public void MoveToObject_SelfTarget_CleansUpImmediately_NoSetTarget() + { + var h = new MoveToManagerHarness(); + h.Manager.MoveToObject(h.SelfId, h.SelfId, 1f, 2f, new MovementParameters()); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Empty(h.SetTargetCalls); + } + + // ── TurnToObject deferred start (§3d + §6g) ──────────────────────────── + + [Fact] + public void TurnToObject_NoNodesQueuedUntilTargetCallback() + { + var h = new MoveToManagerHarness(); + h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters()); + + Assert.Empty(h.Manager.PendingActions); + Assert.False(h.Manager.Initialized); + Assert.Single(h.SetTargetCalls); + } + + [Fact] + public void TurnToObject_FirstCallback_QueuesExactlyOneTurnNode_FacingObject() + { + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; // already facing north — target due EAST forces an actual turn to queue. + + // DesiredHeading is clobbered (§3d quirk) — it should NOT appear in + // the final node heading; the final heading is purely "face the + // object" (soughtHeading is 0 for a fresh manager). + h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { DesiredHeading = 200f }); + + var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 (east) + h.Manager.TurnToObject_Internal(target); + + var nodes = h.Manager.PendingActions.ToList(); + Assert.Single(nodes); + Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); + Assert.Equal(90f, nodes[0].Heading, 2); // face-the-object (east), NOT 200 + Assert.True(h.Manager.Initialized); + } + + [Fact] + public void TurnToObject_FirstCallback_AlreadyFacingObject_CompletesImmediately() + { + // When soughtHeading(0) + targetHeading already equals the current + // heading, BeginTurnToHeading's "already there" branch consumes the + // node on the SAME call — the queue is empty by the time + // TurnToObject_Internal returns, but the manager still passed + // through Initialized=true and the CleanUp/StopCompletely tail. + var h = new MoveToManagerHarness(); + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 0f; + + h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters()); + var target = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 (north) — already facing it. + h.Manager.TurnToObject_Internal(target); + + Assert.Empty(h.Manager.PendingActions); + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void TurnToObject_SelfTarget_CleansUpImmediately() + { + var h = new MoveToManagerHarness(); + h.Manager.TurnToObject(h.SelfId, h.SelfId, new MovementParameters()); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Empty(h.SetTargetCalls); + } + + [Fact] + public void TurnToObject_StopCompletelyOnlyWhenBitSet() + { + var h1 = new MoveToManagerHarness(); + h1.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = false }); + Assert.Equal(0, h1.StopCompletelyCalls); + + var h2 = new MoveToManagerHarness(); + h2.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = true }); + Assert.Equal(1, h2.StopCompletelyCalls); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs new file mode 100644 index 00000000..2f89d999 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs @@ -0,0 +1,295 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — BeginNextNode's sticky handoff (00529cb0, raw +/// 307123-307171, decomp §4b) and CancelMoveTo +/// (00529930, raw 306886-306940, decomp §7c) including the +/// reentrancy invariant (r4-port-plan.md §4: CancelMoveTo → +/// CleanUpAndCallWeenie → StopCompletely → InterruptCurrentMovement → +/// CancelMoveTo no-ops on Invalid). +/// +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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs new file mode 100644 index 00000000..9a3a96c1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — shared scripted fake interp-sink/provider harness for +/// conformance tests (r4-port-plan.md §3 V2: +/// "Use a scripted fake interp-sink/provider harness — NO real sequencer +/// needed; the manager drives the interp seams; assert the call sequences +/// + state"). Wraps a REAL bound to a +/// minimal always-grounded (so +/// _DoMotion/_StopMotion's adjust_motion + +/// DoInterpretedMotion/StopInterpretedMotion chain runs for +/// real, dispatch treated as always-succeeding since no +/// is wired — matching +/// DoInterpretedMotion's documented null-sink posture), and exposes +/// every ctor seam as a mutable, inspectable +/// field so tests can script position/heading/contact/target-tracker +/// behavior and assert on call sequences. +/// +internal sealed class MoveToManagerHarness +{ + public readonly MotionInterpreter Interp = new(); + public readonly PhysicsBody Body = new(); + + /// Scripted world position + cell (defaults to cell 1, origin). + public Position WorldPosition = new(1u, Vector3.Zero, Quaternion.Identity); + + /// Scripted compass heading, degrees (P5 convention). + public float Heading; + + /// Records every SetHeading(heading, send) call. + public readonly List<(float Heading, bool Send)> SetHeadingCalls = new(); + + public float OwnRadius = 0.5f; + public float OwnHeight = 2.0f; + + public bool ContactValue = true; + public bool IsInterpolatingValue; + public Vector3 Velocity = Vector3.Zero; + + public uint SelfId = 0x50000001u; + + /// Records every StopCompletely() call (count only — + /// retail's CPhysicsObj::StopCompletely takes no args at this + /// seam level). + public int StopCompletelyCalls; + + public readonly List<(uint ContextId, uint ObjectId, float Radius, double Quantum)> SetTargetCalls = new(); + public int ClearTargetCalls; + public double TargetQuantum; + public readonly List SetTargetQuantumCalls = new(); + + public int UnstickCalls; + public readonly List<(uint Tlid, float Radius, float Height)> StickToCalls = new(); + public readonly List MoveToCompleteCalls = new(); + + /// Scripted clock — advances by only + /// when a test calls ; reading CurTime alone + /// (e.g. multiple reads within one manager call) does NOT advance it, + /// matching retail's Timer::cur_time being a stable snapshot for + /// the duration of one dispatch. + public double CurTime; + public const double TickSeconds = 1.0 / 30.0; + + public readonly MoveToManager Manager; + + public MoveToManagerHarness() + { + Interp.PhysicsObj = Body; + Body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; + + Manager = new MoveToManager( + Interp, + stopCompletely: () => StopCompletelyCalls++, + getPosition: () => WorldPosition, + getHeading: () => Heading, + setHeading: (h, send) => { SetHeadingCalls.Add((h, send)); Heading = h; }, + getOwnRadius: () => OwnRadius, + getOwnHeight: () => OwnHeight, + contact: () => ContactValue, + isInterpolating: () => IsInterpolatingValue, + getVelocity: () => Velocity, + getSelfId: () => SelfId, + setTarget: (ctx, obj, radius, quantum) => SetTargetCalls.Add((ctx, obj, radius, quantum)), + clearTarget: () => ClearTargetCalls++, + getTargetQuantum: () => TargetQuantum, + setTargetQuantum: q => { TargetQuantum = q; SetTargetQuantumCalls.Add(q); }, + curTime: () => CurTime); + + Manager.StickTo = (tlid, radius, height) => StickToCalls.Add((tlid, radius, height)); + Manager.MoveToComplete = err => MoveToCompleteCalls.Add(err); + Manager.Unstick = () => UnstickCalls++; + } + + /// Advance the scripted clock by one physics tick (1/30 s). + public void Tick() => CurTime += TickSeconds; + + /// Advance the scripted clock by an arbitrary amount. + public void Advance(double seconds) => CurTime += seconds; + + /// + /// Drains the REAL 's + /// pending_motions queue via synthetic MotionDone + /// callbacks — standing in for "the dispatched motion's animation-table + /// cycle finished", which a live AnimationSequencer/ + /// MotionTableManager would signal in production. Every + /// _DoMotion/_StopMotion call that succeeds enqueues a + /// node (retail AddToQueue, decomp's DoInterpretedMotion + /// body); without draining, + /// stays true forever in this bare harness, which would wedge + /// 's wait-for-anims gate + /// and Phase 1's + /// "animating, stop aux" branch permanently. Call after any manager + /// method that dispatches a motion, before asserting on the NEXT tick's + /// behavior. + /// + public void DrainPendingMotions() + { + while (Interp.MotionsPending()) + Interp.MotionDone(0, true); + } + + /// Current interpreted forward command — the observable proxy + /// for "what motion did MoveToManager just dispatch via _DoMotion", + /// since + /// writes through to + /// when ModifyInterpretedState is set (default true). + public uint ForwardCommand => Interp.InterpretedState.ForwardCommand; + + public uint TurnCommand => Interp.InterpretedState.TurnCommand; + + public float ForwardSpeed => Interp.InterpretedState.ForwardSpeed; +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs new file mode 100644 index 00000000..bb536d16 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs @@ -0,0 +1,240 @@ +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — BeginTurnToHeading (00529b90, raw 307046-307120, +/// decomp §4d) and HandleTurnToHeading (0052a0c0, raw +/// 307442-307517, decomp §6c) — the direction-pick table (TurnRight ≤180 vs +/// TurnLeft >180), the "already there" early-outs, the +/// MotionsPending wait gate, the arrival snap +/// ( + the ONE set_heading in +/// the whole family), and the PreviousHeading DIFF-seed quirk. +/// +public sealed class MoveToManagerTurnToHeadingTests +{ + // ── BeginTurnToHeading direction pick (§4d) ──────────────────────────── + + [Theory] + [InlineData(0f, 90f, MotionCommand.TurnRight)] // diff=90 <=180 -> TurnRight + [InlineData(0f, 170f, MotionCommand.TurnRight)] // diff=170 <=180 -> TurnRight + [InlineData(0f, 190f, MotionCommand.TurnLeft)] // diff=190 >180 -> TurnLeft + [InlineData(0f, 270f, MotionCommand.TurnLeft)] // diff=270 >180 -> TurnLeft + public void DirectionPick_Table(float currentHeading, float targetHeading, uint expectedTurn) + { + var h = new MoveToManagerHarness(); + h.Heading = currentHeading; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = targetHeading }); + + Assert.Equal(expectedTurn, h.Manager.CurrentCommand); + } + + [Fact] + public void DirectionPick_ExactlyAt180_TurnRight_NotStrictlyGreater() + { + // diff > 180 is the TurnLeft gate (strict); exactly 180 stays TurnRight. + var h = new MoveToManagerHarness(); + h.Heading = 0f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 180f }); + + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + } + + [Fact] + public void AlreadyThere_DiffLessThanOrEqualEpsilon_PopsImmediately_NoDispatch() + { + var h = new MoveToManagerHarness(); + h.Heading = 90f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + + Assert.Equal(0u, h.Manager.CurrentCommand); + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + Assert.Empty(h.Manager.PendingActions); + } + + [Fact] + public void AlreadyThere_WrappedNearFullCircle_PopsImmediately() + { + // diff > 180 branch's OWN "already there" check: diff + eps >= 360. + var h = new MoveToManagerHarness(); + h.Heading = 0.0001f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 0f }); + + // diff computed via HeadingDiff(0, 0.0001, TurnRight) ~ -0.0001 -> wraps to ~359.9999 + // which is > 180 -> TurnLeft branch -> diff+eps >= 360 check. + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void WaitsForPendingAnimations_BeforeArmingTurn() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + + // Simulate an in-flight animation-table motion BEFORE the turn is armed. + h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0); + Assert.True(h.Interp.MotionsPending()); + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + + // BeginNextNode -> BeginTurnToHeading saw MotionsPending() true and + // returned WITHOUT dispatching — CurrentCommand stays 0, the node + // stays queued. + Assert.Equal(0u, h.Manager.CurrentCommand); + Assert.Single(h.Manager.PendingActions); + Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState); + } + + [Fact] + public void EmptyQueue_CancelsWithNoPhysicsObjectCode() + { + var h = new MoveToManagerHarness(); + // Calling BeginTurnToHeading directly with no queued node -> CancelMoveTo(NoPhysicsObject, per A10). + h.Manager.BeginTurnToHeading(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void PreviousHeadingSeededWithDiff_NotAHeading() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + + // The quirk: PreviousHeading stores the REMAINING DIFF (90), not the + // target heading value coincidentally equal to it here — verify via + // a case where they'd differ. + Assert.Equal(90f, h.Manager.PreviousHeading, 2); + } + + [Fact] + public void PreviousHeadingSeed_DiffersFromTargetHeading_ProvingItsADiffNotAHeading() + { + var h = new MoveToManagerHarness(); + h.Heading = 30f; + + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + + // diff = HeadingDiff(90, 30, TurnRight) = 60 -- NOT 90 (the target + // heading) and NOT 30 (current heading) -- proves PreviousHeading + // stores the DIFF. + Assert.Equal(60f, h.Manager.PreviousHeading, 2); + } + + // ── HandleTurnToHeading (§6c): arrival snap + progress test ──────────── + + [Fact] + public void HandleTurnToHeading_NotCurrentlyTurning_ReArmsViaBeginTurnToHeading() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + h.DrainPendingMotions(); // clear the dispatch so a bare HandleTurnToHeading call doesn't hit the "still turning" path unexpectedly + + // Force CurrentCommand to something that isn't a turn (simulating an + // external interrupt that cleared it without popping the node) — + // exercised via CancelMoveTo would drop everything, so instead just + // confirm the normal flow already armed a turn command. + Assert.True(h.Manager.CurrentCommand is MotionCommand.TurnRight or MotionCommand.TurnLeft); + } + + [Fact] + public void HandleTurnToHeading_Arrival_SnapsHeadingAndSendsTrue() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + h.DrainPendingMotions(); + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + + // Advance heading to just past the target (heading_greater says we + // passed it) -- simulates the turn animation having rotated us there. + h.Heading = 91f; + + h.Manager.HandleTurnToHeading(); + + // The ONE heading snap in the whole family: SetHeading(90, send:true). + Assert.Contains((90f, true), h.SetHeadingCalls); + Assert.Equal(90f, h.Heading, 2); // snapped to the EXACT node heading, not 91. + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // queue drained -> complete. + } + + [Fact] + public void HandleTurnToHeading_StillTurning_RotationalProgress_ResetsFailCounter() + { + // The first post-BeginTurnToHeading tick compares the LIVE heading + // (still 0, unmoved) against PreviousHeading's quirk-seeded DIFF + // value (170, not a heading) — HeadingDiff(0,170,TurnRight)=190, + // outside (eps,180), so tick 1 reads as NO progress (a numeric + // artifact of the seed, not a real stall) and FailProgressCount + // increments once. From tick 2 onward PreviousHeading holds a REAL + // heading and steady rotation reads as genuine progress. + var h = new MoveToManagerHarness(); + h.Heading = 0f; + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f }); + h.DrainPendingMotions(); + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + Assert.Equal(170f, h.Manager.PreviousHeading, 2); // diff-seeded (quirk) + + h.Manager.HandleTurnToHeading(); // tick 1 (heading unmoved) -- the seed artifact tick + Assert.Equal(1u, h.Manager.FailProgressCount); + Assert.Equal(0f, h.Manager.PreviousHeading, 2); + + h.Heading = 90f; // tick 2: rotated 90 deg toward the 170 target, hasn't passed it. + h.Manager.HandleTurnToHeading(); + + Assert.Equal(0u, h.Manager.FailProgressCount); // reset by genuine progress + Assert.Equal(90f, h.Manager.PreviousHeading, 2); // updated to the live heading + } + + [Fact] + public void HandleTurnToHeading_NoRotationalProgress_IncrementsFailCounter_WhenNotAnimating() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f }); + h.DrainPendingMotions(); + + // Heading did not move at all -> HeadingDiff(0, 170, TurnRight): + // seeded PreviousHeading was 170; live heading still 0 -> diff = + // HeadingDiff(0, 170, TurnRight) = -170 -> +360 = 190; the progress + // window is (eps,180) exclusive on the high end -- 190 fails it -> + // no progress -> counter increments (not interpolating, not animating). + h.Manager.HandleTurnToHeading(); + + Assert.Equal(1u, h.Manager.FailProgressCount); + } + + [Fact] + public void HandleTurnToHeading_TurnLeftDirection_UsesMirroredHeadingDiff() + { + var h = new MoveToManagerHarness(); + h.Heading = 0f; + // diff = HeadingDiff(190,0,TurnRight) = 190 > 180 -> TurnLeft chosen. + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 190f }); + h.DrainPendingMotions(); + Assert.Equal(MotionCommand.TurnLeft, h.Manager.CurrentCommand); + + // Rotate counter-clockwise (heading decreasing toward the target + // from the TurnLeft direction) -- heading_greater(-, node.Heading=190, TurnLeft) + // needs the mirror-aware diff test to register progress correctly. + h.Heading = 350f; // moved 10 deg counter-clockwise from 0 (i.e. toward 190 the "left" way) + + h.Manager.HandleTurnToHeading(); + + // Just verifying no crash / a sane FailProgressCount either way — + // the mirror's behavioral effect is dead in retail (§8, P3 + // adjudication: the mirror only affects fail_progress_count + // reset-vs-increment, which is write-only) so this is a smoke test + // for the TurnLeft code path executing without throwing. + Assert.True(h.Manager.FailProgressCount is 0 or 1); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs new file mode 100644 index 00000000..0fb21e48 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs @@ -0,0 +1,150 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.Core.Tests.Physics.Motion; + +/// +/// R4-V2 — UseTime (0052a780, raw 307776-307798, decomp §6a): +/// the three-gate tick matrix (grounded / node-exists / object-move +/// initialized), including the uninitialized type-6 stall case from the +/// port plan's V2 test list. +/// +public sealed class MoveToManagerUseTimeGateTests +{ + [Fact] + public void NoNodeQueued_UseTimeIsANoOp() + { + var h = new MoveToManagerHarness(); + // Fresh manager: no active move, no nodes. + h.Manager.UseTime(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); + } + + [Fact] + public void NotGrounded_ContactFalse_UseTimeDoesNothing_EvenWithNodesQueued() + { + var h = new MoveToManagerHarness { ContactValue = false }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed) + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); + h.DrainPendingMotions(); + uint commandBefore = h.Manager.CurrentCommand; + + // Move the mover without letting UseTime process it (Contact=false blocks the gate). + h.WorldPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); + h.Advance(5.0); + h.Manager.UseTime(); + + // State machine did not advance -- still the same command, same type. + Assert.Equal(commandBefore, h.Manager.CurrentCommand); + Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); + } + + [Fact] + public void Grounded_MoveToPositionNode_DispatchesToHandleMoveToPosition() + { + var h = new MoveToManagerHarness { ContactValue = true }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed) + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }); + h.DrainPendingMotions(); + + // Arrived: move the mover close to the TARGET (20,0,0), well within + // DistanceToObject, and advance time so CheckProgressMade evaluates + // true and the arrival branch pops. + h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity); + h.Advance(2.0); + + h.Manager.UseTime(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleMoveToPosition ran and completed the move. + } + + [Fact] + public void Grounded_TurnToHeadingNode_DispatchesToHandleTurnToHeading() + { + var h = new MoveToManagerHarness { ContactValue = true }; + h.Heading = 0f; + h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); + h.DrainPendingMotions(); + Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); + + h.Heading = 91f; // "passed" the target + h.Manager.UseTime(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleTurnToHeading ran and completed the turn. + } + + [Fact] + public void ObjectMove_UninitializedType6_StallsUntilFirstTargetCallback() + { + // The port plan's named "uninitialized type-6 stall" case: a + // MoveToObject manager with TopLevelObjectId != 0 and + // MovementTypeState != Invalid, but Initialized still false (no + // HandleUpdateTarget callback has arrived yet) -- and CRITICALLY, + // no node is queued yet either (MoveToObject defers node-building + // to the first callback, §3b), so UseTime's node-exists gate (gate + // 2) already blocks it. This test proves the stall holds even if a + // node WERE somehow present (defense in depth for gate 3). + var h = new MoveToManagerHarness { ContactValue = true }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Manager.MoveToObject(0x5000AAAAu, 0x5000AAAAu, 1f, 2f, new MovementParameters()); + + Assert.False(h.Manager.Initialized); + Assert.Empty(h.Manager.PendingActions); // gate 2 alone already stalls it + + h.Manager.UseTime(); + + // No crash, no state change -- the manager is still waiting. + Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState); + Assert.False(h.Manager.Initialized); + } + + [Fact] + public void ObjectMove_Initialized_PassesGate3_ProcessesNormally() + { + var h = new MoveToManagerHarness { ContactValue = true }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; // face the target so the internal node plan skips the turn-to-face step + h.Manager.MoveToObject(0x5000BBBBu, 0x5000BBBBu, radius: 0.5f, height: 2f, new MovementParameters { UseSpheres = false }); + + var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity); + h.Manager.HandleUpdateTarget(new TargetInfo(0x5000BBBBu, TargetStatus.Ok, target, target)); + Assert.True(h.Manager.Initialized); + h.DrainPendingMotions(); + + h.WorldPosition = new Position(1u, new Vector3(19.5f, 0f, 0f), Quaternion.Identity); // within DistanceToObject default 0.6 + h.Advance(2.0); + + h.Manager.UseTime(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // completed via UseTime -> HandleMoveToPosition. + } + + [Fact] + public void NonObjectMove_TopLevelIdZero_Gate3AlwaysPasses_RegardlessOfInitialized() + { + // Gate 3: (top_level_object_id == 0 || movement_type == Invalid) || + // initialized. Position/heading moves never set TopLevelObjectId, + // so the FIRST disjunct alone always satisfies gate 3 -- Initialized + // staying false (as it does for MoveToPosition/TurnToHeading, per + // §3c/§3e's notes) never blocks them. + var h = new MoveToManagerHarness { ContactValue = true }; + h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); + h.Heading = 90f; + h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }); + h.DrainPendingMotions(); + + Assert.Equal(0u, h.Manager.TopLevelObjectId); + Assert.False(h.Manager.Initialized); + + h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity); + h.Advance(2.0); + h.Manager.UseTime(); + + Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // gate 3 passed via the first disjunct. + } +}