acdream/src/AcDream.Core/Physics/Motion/MoveToManager.cs
Erik 4cad626ff5 chore(#170): strip the temp probes, close the issue (user visual gate PASSED)
Gate result: user side-by-side vs retail — "looks good, as close to retail
now as I can see". Telemetry from the gate session (launch-170-gate2.log):
BeginMoveForward ~1:1 with MoveToObject arms for every chasing creature
(pre-fix capture was 16:1), ZERO "SERVERVEL skips UseTime while armed"
occurrences, pending_motions depth 1 at last sample.

Stripped per the #170 close-out plan: s_mvtoDiag + all [mvto] prints
(MoveToManager), s_drainDiag + [drain]/[drainq] (MotionInterpreter), the
[npc-tick] branch prints and the "UM actions" inbound-action dump
(GameWindow). The durable ACDREAM_DUMP_MOTION family stays. The
RemoteChaseEndToEndHarnessTests + RemoteChaseDrainBisectTests conformance
suites stay as the permanent regression net for this pipeline.

ISSUES: #170 -> DONE + Recently-closed entry (fix SHAs 427332ac, d2ccc80e,
1051fc83). Memory topic + index flipped to CLOSED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:31:00 +02:00

1634 lines
70 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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).
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// R4-V2 — verbatim port of retail's <c>MoveToManager</c> (acclient.h:31473,
/// struct #3462). Drives an entity's chase/flee/turn-to/turn-to-heading
/// state machine: builds a small node plan (<see cref="MoveToNode"/>,
/// <c>pending_actions</c>), steps it every tick via <see cref="UseTime"/>,
/// and issues the resulting motion commands through the SAME
/// <see cref="MotionInterpreter.DoInterpretedMotion(uint,MovementParameters)"/>/
/// <see cref="MotionInterpreter.StopInterpretedMotion(uint,MovementParameters)"/>
/// seam every other interpreted motion uses (<c>_DoMotion</c>/<c>_StopMotion</c>,
/// §7a/§7b — MoveToManager NEVER calls <c>DoMotion</c>, <c>set_hold_run</c>, or
/// any raw-state API directly).
///
/// <para>
/// <b>Construction contract (r4-port-plan.md §4):</b> ctor-injected with the
/// entity's <see cref="MotionInterpreter"/> (the <c>_DoMotion</c> target) plus
/// a set of seam delegates standing in for retail's <c>CPhysicsObj</c>
/// 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
/// (<see cref="Unstick"/>, <see cref="StickTo"/>, <see cref="MoveToComplete"/>)
/// which mirror the no-op-until-bound convention already used by
/// <see cref="MotionInterpreter.UnstickFromObject"/> /
/// <see cref="MotionInterpreter.InterruptCurrentMovement"/>.
/// </para>
///
/// <para>
/// <b>The "no physics_obj" guard.</b> Retail gates almost every member on
/// <c>this-&gt;physics_obj != 0</c> (a raw pointer that can be null before
/// <c>SetPhysicsObject</c> is called, or after the owning <c>CPhysicsObj</c>
/// is destroyed). This port models the SAME guard via <see cref="HasPhysicsObj"/>
/// (settable, defaults true) rather than requiring callers to pass a nullable
/// body reference through every seam — the seam delegates themselves are
/// assumed valid whenever <see cref="HasPhysicsObj"/> is true (matching
/// retail's single non-null pointer covering the whole entry-point family).
/// </para>
/// </summary>
public sealed class MoveToManager
{
private readonly MotionInterpreter _interp;
// ── seam delegates (ctor-injected; r4-port-plan.md §4) ─────────────────
/// <summary>Retail <c>CPhysicsObj::StopCompletely</c> — routes through
/// <c>MovementStruct{type=5}</c> to the SAME interp
/// (<see cref="MotionInterpreter.StopCompletely"/> pre-R5; direct call is
/// the same body).</summary>
private readonly Action _stopCompletely;
/// <summary>Retail <c>physics_obj-&gt;m_position</c> read (world position
/// + cell id) — the CURRENT position, sampled fresh every call site that
/// reads <c>myPos</c>/<c>curPos</c>.</summary>
private readonly Func<Position> _getPosition;
/// <summary>Retail <c>CPhysicsObj::get_heading</c> — current compass
/// heading, degrees (P5 convention).</summary>
private readonly Func<float> _getHeading;
/// <summary>Retail <c>CPhysicsObj::set_heading(heading, send)</c> — the
/// ONE heading snap in the whole family
/// (<see cref="HandleTurnToHeading"/>'s arrival snap, decomp §6c
/// @0052a146). <c>send</c> flags the outbound network echo (remotes:
/// no-op send per the wiring contract).</summary>
private readonly Action<float, bool> _setHeading;
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the MOVER's own
/// radius (feeds <see cref="GetCurrentDistance"/>'s cylinder-distance
/// own-side argument).</summary>
private readonly Func<float> _getOwnRadius;
/// <summary>Retail <c>CPhysicsObj::GetHeight</c> — the MOVER's own
/// height.</summary>
private readonly Func<float> _getOwnHeight;
/// <summary>Retail <c>physics_obj-&gt;transient_state &amp; 1</c> —
/// CONTACT bit (<see cref="UseTime"/>'s tick gate, decomp §6a).</summary>
private readonly Func<bool> _contact;
/// <summary>Retail <c>CPhysicsObj::IsInterpolating</c> →
/// <c>PositionManager::IsInterpolating</c> — consumed by the
/// fail-progress stall tests (decomp §6b/§6c).</summary>
private readonly Func<bool> _isInterpolating;
/// <summary>Retail <c>CPhysicsObj::get_velocity</c> — feeds the Phase-3
/// TargetManager quantum retune (decomp §6b Phase 3).</summary>
private readonly Func<Vector3> _getVelocity;
/// <summary>Retail <c>physics_obj-&gt;id</c> — the mover's own object id
/// (self-target detection in MoveToObject/TurnToObject, §3b/§3d).</summary>
private readonly Func<uint> _getSelfId;
/// <summary>Retail <c>CPhysicsObj::set_target(context_id, object_id,
/// radius, quantum)</c> → <c>TargetManager::SetTarget</c> (call shape
/// only, decomp §9f — R5 owns the body). MoveToManager always passes
/// <c>(0, top_level_object_id, 0.5f, 0.0)</c>.</summary>
private readonly Action<uint, uint, float, double> _setTarget;
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
/// <c>TargetManager::ClearTarget</c> (call shape only).</summary>
private readonly Action _clearTarget;
/// <summary>Retail <c>CPhysicsObj::get_target_quantum</c> (call shape
/// only; ACE: returns <c>TargetInfo.Quantum</c>, default 0).</summary>
private readonly Func<double> _getTargetQuantum;
/// <summary>Retail <c>CPhysicsObj::set_target_quantum</c> →
/// <c>TargetManager::SetTargetQuantum</c> (call shape only).</summary>
private readonly Action<double> _setTargetQuantum;
/// <summary>Retail <c>CPhysicsObj::unstick_from_object</c> →
/// <c>PositionManager::UnStick</c> (call shape only, R5 body). Called at
/// the head of every <see cref="PerformMovement"/> (§3a). Optional —
/// null is a silent no-op, matching
/// <see cref="MotionInterpreter.UnstickFromObject"/>'s convention.</summary>
public Action? Unstick { get; set; }
/// <summary>Retail <c>PositionManager::StickTo(object_id, radius,
/// height)</c> (call shape only, R5 StickyManager body) — the sticky
/// arrival handoff in <see cref="BeginNextNode"/> (§4b). Optional —
/// null is a silent no-op.</summary>
public Action<uint, float, float>? StickTo { get; set; }
/// <summary>
/// CLIENT ADDITION — NOT retail (decomp §7e / do-not-invent list):
/// <c>CleanUpAndCallWeenie</c> contains no weenie call in this build
/// (the name is vestigial; body ≡ CleanUp + StopCompletely), and retail
/// notifies NOTHING on arrival (§4b's empty-queue completion is inline
/// CleanUp + StopCompletely). This seam stands in for ACE's server-side
/// <c>OnMoveComplete</c> notification so the App layer can re-anchor
/// AD-27 (Use/PickUp re-send on arrival). Fires with
/// <see cref="WeenieError.None"/> on NATURAL COMPLETION — the
/// <see cref="BeginNextNode"/> empty-queue exits (both sticky and
/// non-sticky) and <see cref="CleanUpAndCallWeenie"/>'s instant-success
/// path — and NEVER from <see cref="CancelMoveTo"/>/plain
/// <see cref="CleanUp"/> (a cancel is not an arrival; AD-27's re-send
/// must not fire on user interrupt). Optional — null is a silent no-op.
/// </summary>
public Action<WeenieError>? MoveToComplete { get; set; }
/// <summary>Retail <c>Timer::cur_time</c> — 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).</summary>
private readonly Func<double> _curTime;
/// <summary>
/// Retail's <c>physics_obj != 0</c> 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.
/// </summary>
public bool HasPhysicsObj { get; set; } = true;
public MoveToManager(
MotionInterpreter interp,
Action stopCompletely,
Func<Position> getPosition,
Func<float> getHeading,
Action<float, bool> setHeading,
Func<float> getOwnRadius,
Func<float> getOwnHeight,
Func<bool> contact,
Func<bool> isInterpolating,
Func<Vector3> getVelocity,
Func<uint> getSelfId,
Action<uint, uint, float, double> setTarget,
Action clearTarget,
Func<double> getTargetQuantum,
Action<double> setTargetQuantum,
Func<double>? 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) ──────────────
/// <summary>+0x00 <c>movement_type</c>.</summary>
public MovementType MovementTypeState { get; private set; } = MovementType.Invalid;
/// <summary>
/// Retail's default-constructed <c>Position</c> (identity
/// <c>CellFrame</c>, cell id 0) — NOT C#'s <c>default(Position)</c>,
/// whose <c>default(Quaternion)</c> is the ZERO quaternion (0,0,0,0),
/// not the identity rotation (0,0,0,1). <c>GetHeading</c> on a zero
/// quaternion is degenerate (does not read as "heading 0" — the
/// underlying <c>Vector3.Transform</c> collapses to zero). Retail's
/// ctor (§1a) and <see cref="InitializeLocalVariables"/> (§1c) both
/// reset these fields to a genuine identity frame.
/// </summary>
private static readonly Position IdentityPosition = new(0u, Vector3.Zero, Quaternion.Identity);
/// <summary>+0x04 <c>sought_position</c>.</summary>
public Position SoughtPosition { get; private set; } = IdentityPosition;
/// <summary>+0x4C <c>current_target_position</c>.</summary>
public Position CurrentTargetPosition { get; private set; } = IdentityPosition;
/// <summary>+0x94 <c>starting_position</c>.</summary>
public Position StartingPosition { get; private set; } = IdentityPosition;
/// <summary>+0xDC <c>movement_params</c> (the 10-field copy every entry
/// point performs).</summary>
public MovementParameters Params { get; private set; } = new();
/// <summary>+0x108 <c>previous_heading</c>.</summary>
public float PreviousHeading { get; private set; }
/// <summary>+0x10C <c>previous_distance</c>.</summary>
public float PreviousDistance { get; private set; }
/// <summary>+0x110 <c>previous_distance_time</c>.</summary>
public double PreviousDistanceTime { get; private set; }
/// <summary>+0x118 <c>original_distance</c>.</summary>
public float OriginalDistance { get; private set; }
/// <summary>+0x120 <c>original_distance_time</c>.</summary>
public double OriginalDistanceTime { get; private set; }
/// <summary>+0x128 <c>fail_progress_count</c> — WRITE-ONLY (decomp §8;
/// do-not-invent: no give-up threshold exists in retail). Exposed for
/// conformance-test inspection only.</summary>
public uint FailProgressCount { get; private set; }
/// <summary>+0x12C <c>sought_object_id</c>.</summary>
public uint SoughtObjectId { get; private set; }
/// <summary>+0x130 <c>top_level_object_id</c>.</summary>
public uint TopLevelObjectId { get; private set; }
/// <summary>+0x134 <c>sought_object_radius</c>.</summary>
public float SoughtObjectRadius { get; private set; }
/// <summary>+0x138 <c>sought_object_height</c>.</summary>
public float SoughtObjectHeight { get; private set; }
/// <summary>+0x13C <c>current_command</c>.</summary>
public uint CurrentCommand { get; private set; }
/// <summary>+0x140 <c>aux_command</c>.</summary>
public uint AuxCommand { get; private set; }
/// <summary>+0x144 <c>moving_away</c>.</summary>
public bool MovingAway { get; private set; }
/// <summary>+0x148 <c>initialized</c>.</summary>
public bool Initialized { get; private set; }
/// <summary>+0x14C/+0x150 <c>pending_actions</c> (DLList) — ported as a
/// managed <see cref="LinkedList{T}"/> of <see cref="MoveToNode"/>, tail-append
/// / head-pop (matching <c>InsertAfter</c>/<c>RemovePendingActionsHead</c>
/// shape). Exposed read-only for conformance tests.</summary>
private readonly LinkedList<MoveToNode> _pendingActions = new();
/// <summary>Read-only inspection surface (tests): the node queue in
/// head-to-tail order.</summary>
public IEnumerable<MoveToNode> PendingActions => _pendingActions;
// ── 1. Creation / lifecycle ─────────────────────────────────────────────
/// <summary>
/// Retail <c>MoveToManager::InitializeLocalVariables</c>
/// (<c>00529250</c>, raw 306490-306534). VERBATIM per decomp §1c: zeroes
/// <see cref="MovementTypeState"/> (Invalid) and the params BITFIELD +
/// CONTEXT ID ONLY (<c>movement_params.__inner0 = 0;
/// movement_params.context_id = 0;</c> — 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 <c>FLT_MAX</c>/now (
/// <see cref="PreviousDistance"/>/<see cref="OriginalDistance"/>),
/// <see cref="PreviousHeading"/>=0, <see cref="FailProgressCount"/>=0,
/// <see cref="CurrentCommand"/>/<see cref="AuxCommand"/>=0,
/// <see cref="MovingAway"/>/<see cref="Initialized"/>=false, resets
/// <see cref="SoughtPosition"/>/<see cref="CurrentTargetPosition"/> to
/// cell 0 + identity frame (<see cref="StartingPosition"/> is NOT
/// touched here), and zeroes <see cref="SoughtObjectId"/>/
/// <see cref="TopLevelObjectId"/>/<see cref="SoughtObjectRadius"/>/
/// <see cref="SoughtObjectHeight"/>. Does NOT drain
/// <see cref="_pendingActions"/> — callers (<see cref="Destroy"/>/
/// <see cref="CancelMoveTo"/>) drain first.
/// </summary>
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;
}
/// <summary>
/// Retail <c>MoveToManager::Destroy</c> (<c>005294b0</c>, raw
/// 306618-306663): drains <see cref="_pendingActions"/> then tailcalls
/// <see cref="InitializeLocalVariables"/>.
/// </summary>
public void Destroy()
{
_pendingActions.Clear();
InitializeLocalVariables();
}
/// <summary>
/// Retail <c>MoveToManager::is_moving_to</c> (<c>00529220</c>, raw
/// 306464-306470): <c>movement_type != Invalid</c>.
/// </summary>
public bool IsMovingTo() => MovementTypeState != MovementType.Invalid;
// ── 3. Entry points (movement_type setters) ─────────────────────────────
/// <summary>
/// Retail <c>MoveToManager::PerformMovement</c> (<c>0052a900</c>, raw
/// 307871-307904). VERBATIM per decomp §3a: cancels any in-flight moveto
/// (<see cref="CancelMoveTo"/> with <see cref="WeenieError.ActionCancelled"/>
/// 0x36) and unsticks FIRST, unconditionally, before the 4-way type
/// dispatch (MoveToObject/MoveToPosition/TurnToObject/TurnToHeading).
/// Always returns <see cref="WeenieError.None"/> — moveto errors surface
/// via <see cref="CancelMoveTo"/>, never via this return value (decomp
/// §2b note, carried through §3a).
/// </summary>
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;
}
/// <summary>
/// Retail <c>MoveToManager::MoveToObject</c> (<c>00529680</c>, raw
/// 306756-306817). VERBATIM per decomp §3b: StopCompletely, snapshot
/// <see cref="StartingPosition"/>, store sought object id/radius/height,
/// set <see cref="MovementTypeState"/>=MoveToObject,
/// <see cref="TopLevelObjectId"/>, copy all ten <paramref name="p"/>
/// fields into <see cref="Params"/>, <see cref="Initialized"/>=false.
/// Self-target (<paramref name="topLevelId"/> == the mover's own id) →
/// <see cref="CleanUp"/> + StopCompletely (degenerate instant no-op).
/// Otherwise → <see cref="_setTarget"/>(0, topLevelId, 0.5f, 0.0) — the
/// P4 TargetTracker seam. NO nodes are queued yet — object moves are
/// deferred until the first <see cref="HandleUpdateTarget"/> callback
/// (§6). NOTE: <see cref="MovementParameters.Sticky"/> is PRESERVED here
/// (unlike position/turn-heading moves, which force it off — §3c/§3e).
/// </summary>
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);
}
}
/// <summary>
/// Retail <c>MoveToManager::MoveToPosition</c> (<c>0052a240</c>, raw
/// 307521-307593). VERBATIM per decomp §3c: StopCompletely, snapshot
/// <see cref="CurrentTargetPosition"/>=<paramref name="target"/>,
/// <see cref="SoughtObjectRadius"/>=0, compute
/// <see cref="GetCurrentDistance"/>, compute the heading-to-target minus
/// current heading (epsilon-snapped/wrapped, same [0,360) normalization
/// used throughout), <see cref="MovementParameters.GetCommand"/> to see
/// if ANY motion is needed — if so, queue
/// <c>[TurnToHeading(face target)] → [MoveToPosition]</c>. If
/// <see cref="MovementParameters.UseFinalHeading"/> (0x40) is set, ALSO
/// queue a final <c>TurnToHeading(desired_heading)</c> (absolute, unlike
/// <see cref="MoveToObject_Internal"/>'s relative form). Then snapshot
/// <see cref="SoughtPosition"/>/<see cref="StartingPosition"/>, set
/// <see cref="MovementTypeState"/>=MoveToPosition, copy all ten
/// <paramref name="p"/> fields into <see cref="Params"/>, CLEAR the
/// sticky bit (0x80 — position moves and heading turns can never stick;
/// only object moves preserve it), then <see cref="BeginNextNode"/>.
///
/// <para>
/// Reads the ARGUMENT's <see cref="MovementParameters.UseFinalHeading"/>
/// (<paramref name="p"/>), NOT a stale member — the do-not-invent list's
/// "A1 stale-member UseFinalHeading read" ACE-ism is explicitly NOT
/// copied here.
/// </para>
/// </summary>
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();
}
/// <summary>
/// Retail <c>MoveToManager::TurnToObject</c> (<c>005297d0</c>, raw
/// 306820-306882). VERBATIM per decomp §3d, INCLUDING the desired-heading
/// clobber quirk: <paramref name="p"/>.StopCompletelyFlag's underlying bit
/// (0x10000) — here, <see cref="MovementParameters.StopCompletelyFlag"/> —
/// gates the StopCompletely call CONDITIONALLY (unlike MoveToObject/
/// MoveToPosition's UNCONDITIONAL stop). Seeds
/// <c>current_target_position.frame</c>'s heading from
/// <paramref name="p"/>.DesiredHeading — but this write is DISCARDED:
/// <see cref="TurnToObject_Internal"/> later overwrites
/// <see cref="CurrentTargetPosition"/> wholesale and reads
/// <see cref="SoughtPosition"/>'s heading instead (which is 0 after
/// <see cref="InitializeLocalVariables"/> 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 → <see cref="CleanUp"/> + StopCompletely. Otherwise →
/// <see cref="Initialized"/>=false + <see cref="_setTarget"/>(0,
/// topLevelId, 0.5f, 0.0). Deferred like MoveToObject — no nodes queued
/// here; <see cref="TurnToObject_Internal"/> queues on the first target
/// callback.
/// </summary>
public void TurnToObject(uint objectId, uint topLevelId, MovementParameters p)
{
if (!HasPhysicsObj) return;
if (p.StopCompletelyFlag)
{
_stopCompletely();
}
MovementTypeState = MovementType.TurnToObject;
SoughtObjectId = objectId;
// Frame::set_heading(&current_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);
}
}
/// <summary>
/// Retail <c>MoveToManager::TurnToHeading</c> (<c>0052a630</c>, raw
/// 307706-307772). VERBATIM per decomp §3e: conditional StopCompletely
/// (0x10000 stop_completely bit, same shape as
/// <see cref="TurnToObject"/>), copy all ten <paramref name="p"/> fields,
/// CLEAR sticky (0x80), seed <see cref="SoughtPosition"/>'s frame heading
/// from <paramref name="p"/>.DesiredHeading, set
/// <see cref="MovementTypeState"/>=TurnToHeading, queue ONE
/// <see cref="MoveToNode"/> (type TurnToHeading, heading=DesiredHeading)
/// directly (inlined <c>AddTurnToHeadingNode</c> shape — same effect),
/// then IMMEDIATE <see cref="BeginNextNode"/> — unlike ACE's A4 gap
/// (one-tick-late), retail calls <c>BeginNextNode</c> synchronously
/// inside this entry point. <see cref="Initialized"/> is NOT set here
/// (stays whatever it was) — the <see cref="UseTime"/> gate (§6a) passes
/// anyway because <see cref="TopLevelObjectId"/> is 0 for non-object
/// moves (TurnToHeading never sets it).
/// </summary>
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 ─────────────────────────────────────────────────────
/// <summary>
/// Retail <c>MoveToManager::AddTurnToHeadingNode</c> (<c>00529530</c>,
/// raw 306667-306685): tail-append a TurnToHeading node.
/// </summary>
public void AddTurnToHeadingNode(float heading)
=> _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, heading));
/// <summary>
/// Retail <c>MoveToManager::AddMoveToPositionNode</c> (<c>00529580</c>,
/// raw 306689-306706): tail-append a MoveToPosition node (heading
/// unused).
/// </summary>
public void AddMoveToPositionNode()
=> _pendingActions.AddLast(new MoveToNode(MovementType.MoveToPosition, 0f));
/// <summary>
/// Retail <c>MoveToManager::RemovePendingActionsHead</c>
/// (<c>00529380</c>, raw 306538-306550): unlink + delete the head node.
/// </summary>
public void RemovePendingActionsHead()
{
if (_pendingActions.First is not null)
_pendingActions.RemoveFirst();
}
/// <summary>
/// Retail <c>MoveToManager::BeginNextNode</c> (<c>00529cb0</c>, raw
/// 307123-307171). VERBATIM per decomp §4b — THE STICKY HANDOFF: if a
/// node exists, tailcall <see cref="BeginMoveForward"/> (type
/// MoveToPosition) or <see cref="BeginTurnToHeading"/> (type
/// TurnToHeading); an unknown node type stalls (defensive, matches the
/// raw's <c>if/if</c> shape — no <c>else</c> fallthrough). Empty queue =
/// moveto complete: if <see cref="MovementParameters.Sticky"/> (byte0
/// sign bit 0x80) is set, READ <see cref="SoughtObjectRadius"/>/
/// <see cref="SoughtObjectHeight"/>/<see cref="TopLevelObjectId"/>
/// BEFORE <see cref="CleanUp"/> (which zeroes them), THEN CleanUp, THEN
/// StopCompletely, THEN hand off to <see cref="StickTo"/> (R5
/// StickyManager seam) with the pre-CleanUp values. Non-sticky empty
/// queue: CleanUp + StopCompletely only.
/// </summary>
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);
// CLIENT ADDITION (see MoveToComplete doc): natural completion.
// Reentrancy-safe: CleanUp reset movement_type to Invalid BEFORE
// the stop, so the stop's interrupt→CancelMoveTo chain no-oped.
MoveToComplete?.Invoke(WeenieError.None);
return;
}
CleanUp();
if (HasPhysicsObj) _stopCompletely();
// CLIENT ADDITION (see MoveToComplete doc): natural completion.
MoveToComplete?.Invoke(WeenieError.None);
}
/// <summary>
/// Retail <c>MoveToManager::BeginMoveForward</c> (<c>00529a00</c>, raw
/// 306957-307042). VERBATIM per decomp §4c: no physics_obj →
/// <see cref="CancelMoveTo"/>(<see cref="WeenieError.NoPhysicsObject"/>).
/// Compute distance + heading-diff-to-target (same epsilon
/// snap/normalize as <see cref="MoveToPosition"/>), then
/// <see cref="MovementParameters.GetCommand"/>. If no motion is needed
/// (already in range) — pop the head node and
/// <see cref="BeginNextNode"/>. Otherwise build a fresh LOCAL params
/// (defaults; <see cref="MovementParameters.CancelMoveTo"/> CLEARED so
/// <c>_DoMotion</c> doesn't cancel THIS moveto; speed copied from
/// <see cref="Params"/>), issue via <see cref="_DoMotion"/> — on error,
/// <see cref="CancelMoveTo"/> with that error and return. On success,
/// record <see cref="CurrentCommand"/>/<see cref="MovingAway"/>, write
/// the CHOSEN hold key BACK to the stored <see cref="Params"/>
/// (<c>HoldKeyToApply</c>), and seed BOTH progress-clock pairs
/// (<see cref="PreviousDistance"/>/<see cref="OriginalDistance"/> = dist,
/// both times = now).
/// </summary>
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();
}
/// <summary>
/// Retail <c>MoveToManager::BeginTurnToHeading</c> (<c>00529b90</c>, raw
/// 307046-307120). VERBATIM per decomp §4d: empty queue OR no
/// physics_obj → <see cref="CancelMoveTo"/>(NoPhysicsObject, per A10 —
/// NOT ACE's throw). If animations are still pending
/// (<see cref="MotionInterpreter.MotionsPending"/>), WAIT (return — do
/// not start the turn yet). Otherwise read the head node's heading, call
/// <see cref="MoveToMath.HeadingDiff"/> with the CONSTANT
/// <see cref="MotionCommand.TurnRight"/> (mirror explicitly disabled —
/// direction pick uses the raw ≤180 test, not the mirrored value):
/// <c>diff &gt; 180</c> → check "already there" (<c>diff + eps &gt;=
/// 360</c>) and pop+advance if so, else TurnLeft; <c>diff &lt;= 180</c>
/// → check "already there" (<c>diff &lt;= eps</c>) and pop+advance if
/// so, else TurnRight. Issue the turn via <see cref="_DoMotion"/> with a
/// fresh local params (CancelMoveTo cleared, speed + hold_key copied
/// from <see cref="Params"/>) — on error, <see cref="CancelMoveTo"/>. On
/// success, record <see cref="CurrentCommand"/> and store the REMAINING
/// DIFF (not a heading!) into <see cref="PreviousHeading"/> — THE QUIRK:
/// <see cref="HandleTurnToHeading"/>'s progress test then compares a
/// live HEADING against this DIFF-shaped seed on its first tick. Keep
/// verbatim (ACE matches: <c>PreviousHeading = diff</c>).
/// </summary>
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 ─────────────────
/// <summary>
/// Retail <c>MoveToManager::GetCurrentDistance</c> (<c>005291b0</c>, 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 <see cref="HasPhysicsObj"/> is already known true). When
/// <see cref="MovementParameters.UseSpheres"/> (0x400) is NOT set → plain
/// center (Euclidean) distance to <see cref="CurrentTargetPosition"/>.
/// When SET → <see cref="MoveToMath.CylinderDistance"/> using the
/// mover's own radius/height (<see cref="_getOwnRadius"/>/
/// <see cref="_getOwnHeight"/>) vs the stored
/// <see cref="SoughtObjectRadius"/>/<see cref="SoughtObjectHeight"/> —
/// object moves (which set sought radius/height and get use_spheres on
/// the wire) use edge-to-edge distance; position moves use center
/// distance.
/// </summary>
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);
}
/// <summary>
/// Retail <c>MoveToManager::CheckProgressMade</c> (<c>005290f0</c>, raw
/// 306385-306431). VERBATIM per decomp §5b: evaluated only after a
/// 1-SECOND window since <see cref="PreviousDistanceTime"/> (before that,
/// returns true — "OK, too soon to judge"). Progress = distance closed
/// (or opened, when <see cref="MovingAway"/>) since the last checkpoint;
/// requires BOTH the incremental rate (since
/// <see cref="PreviousDistanceTime"/>) AND the overall rate (since
/// <see cref="OriginalDistanceTime"/>) to be ≥ 0.25 units/second. The
/// incremental checkpoint (<see cref="PreviousDistance"/>/
/// <see cref="PreviousDistanceTime"/>) only advances when the
/// incremental rate passes.
/// </summary>
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 ────────────────────────────────
/// <summary>
/// Retail <c>MoveToManager::UseTime</c> (<c>0052a780</c>, raw
/// 307776-307798) — THE TICK GATE. VERBATIM per decomp §6a: three gates,
/// ALL must pass: (1) grounded — <see cref="_contact"/>() (CONTACT
/// transient-state bit; no moveto progress mid-air, <see cref="HitGround"/>
/// resumes on landing); (2) a pending node exists; (3) object-moves
/// (<see cref="TopLevelObjectId"/> != 0 AND
/// <see cref="MovementTypeState"/> != Invalid) must be
/// <see cref="Initialized"/> — i.e. the first target update has arrived.
/// Dispatches to <see cref="HandleMoveToPosition"/> (node type 7) or
/// <see cref="HandleTurnToHeading"/> (node type 9).
/// </summary>
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();
}
}
/// <summary>
/// Retail <c>MoveToManager::HandleMoveToPosition</c> (<c>00529d80</c>,
/// raw 307187-307438) — the big per-tick driver. VERBATIM per decomp
/// §6b, three phases:
/// <list type="number">
/// <item><description><b>Phase 1 — aux turn steering</b> (only when NOT
/// animating, i.e. <see cref="MotionInterpreter.MotionsPending"/> is
/// false): compute the desired heading
/// (<see cref="MovementParameters.GetDesiredHeading"/> 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 <see cref="_DoMotion"/> ONLY if it differs
/// from the currently-running <see cref="AuxCommand"/> (no redundant
/// re-issue). While animating, stop any active aux turn instead (can't
/// steer mid-animation).</description></item>
/// <item><description><b>Phase 2 — arrival / progress</b>: compute
/// distance, run <see cref="CheckProgressMade"/>. On NO progress
/// (returns false): increment <see cref="FailProgressCount"/> (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 —
/// <c>moving_away ? dist &gt;= MinDistance : dist &lt;= DistanceToObject</c>.
/// NOT arrived: check <see cref="MovementParameters.FailDistance"/> against
/// the distance traveled from <see cref="StartingPosition"/> —
/// <see cref="WeenieError.YouChargedTooFar"/> (0x3D) if exceeded. ARRIVED:
/// pop the head node, stop the current+aux motions, clear both command
/// fields, <see cref="BeginNextNode"/>.</description></item>
/// <item><description><b>Phase 3 — TargetManager quantum retune</b>
/// (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.</description></item>
/// </list>
/// <b>ACE-divergence trap (do-not-invent):</b> retail has NO
/// <c>set_heading</c> call anywhere in this method (ACE's "custom: sync
/// for server ticrate" addition is NOT ported).
/// </summary>
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);
}
}
}
}
/// <summary>
/// Retail <c>MoveToManager::HandleTurnToHeading</c> (<c>0052a0c0</c>, raw
/// 307442-307517). VERBATIM per decomp §6c: no physics_obj →
/// <see cref="CancelMoveTo"/>(NoPhysicsObject). If NOT currently turning
/// (<see cref="CurrentCommand"/> isn't TurnLeft/TurnRight) — re-enter
/// <see cref="BeginTurnToHeading"/> (arms the turn). Otherwise: if
/// <see cref="MoveToMath.HeadingGreater"/> says the current heading has
/// PASSED the node's target heading (direction-aware per
/// <see cref="CurrentCommand"/>) — reset the fail counter, SNAP the
/// heading to the node's exact value via <see cref="_setHeading"/>
/// (<c>send:true</c> — THE ONE heading snap in the whole family, decomp
/// §6c @0052a146), pop the node, stop the current motion, clear
/// <see cref="CurrentCommand"/>, <see cref="BeginNextNode"/>. Otherwise —
/// still turning: compute <see cref="MoveToMath.HeadingDiff"/> between
/// the LIVE current heading and <see cref="PreviousHeading"/> (the
/// quirk-seeded DIFF from <see cref="BeginTurnToHeading"/>), passing the
/// LIVE <see cref="CurrentCommand"/> (can be TurnLeft — the mirror DOES
/// apply here, P3). If the result is inside <c>(eps, 180)</c> — rotational
/// progress was made: reset fail counter, update
/// <see cref="PreviousHeading"/> to the LIVE heading. Otherwise — no
/// progress: update <see cref="PreviousHeading"/> anyway, then increment
/// <see cref="FailProgressCount"/> ONLY if neither interpolating nor
/// animating.
/// </summary>
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;
}
}
/// <summary>
/// Retail <c>MoveToManager::HandleUpdateTarget</c> (<c>0052a7d0</c>, raw
/// 307802-307867). VERBATIM per decomp §6d — the P4 TargetTracker feed
/// point: <paramref name="info"/> arrives at CONTEXT 0 only (the caller's
/// responsibility — <c>CPhysicsObj::HandleUpdateTarget</c> gates on
/// <c>context_id == 0</c> before relaying, decomp §9f; not re-checked
/// here). No physics_obj → <see cref="CancelMoveTo"/>(NoPhysicsObject).
/// Ignore updates for any target other than
/// <see cref="TopLevelObjectId"/>. TWO paths:
/// <list type="bullet">
/// <item><description><b>Deferred start</b> (<see cref="Initialized"/>
/// false — the FIRST callback): self-target → snapshot both positions to
/// the mover's own current position and
/// <see cref="CleanUpAndCallWeenie"/>(None) — instant success. Non-OK
/// status → <see cref="CancelMoveTo"/>(<see cref="WeenieError.NoObject"/>
/// 0x38 — the target never resolved). Otherwise build the node plan via
/// <see cref="MoveToObject_Internal"/> (MoveToObject) or
/// <see cref="TurnToObject_Internal"/> (TurnToObject).</description></item>
/// <item><description><b>Retarget while running</b>
/// (<see cref="Initialized"/> true): non-OK status →
/// <see cref="CancelMoveTo"/>(<see cref="WeenieError.ObjectGone"/> 0x37 —
/// was tracked, now gone). Otherwise, for MoveToObject ONLY: update
/// <see cref="SoughtPosition"/>=interpolated,
/// <see cref="CurrentTargetPosition"/>=target, and RESET both
/// progress-clock pairs to FLT_MAX/now. Does NOT requeue nodes — the
/// running MoveToPosition node keeps steering toward the moved
/// <see cref="CurrentTargetPosition"/> each tick (Phase 1 of
/// <see cref="HandleMoveToPosition"/>). TurnToObject gets NO retarget
/// handling (heading was frozen at the initial
/// callback).</description></item>
/// </list>
/// </summary>
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();
}
}
}
/// <summary>
/// Retail <c>MoveToManager::HitGround</c> (<c>00529d70</c>, raw
/// 307175-307183): no-op when <see cref="MovementTypeState"/> is Invalid;
/// otherwise tailcall <see cref="BeginNextNode"/> — re-arm the moveto
/// state machine after landing (mirrors the UseTime CONTACT gate, which
/// blocks all progress while airborne).
/// </summary>
public void HitGround()
{
if (MovementTypeState == MovementType.Invalid) return;
BeginNextNode();
}
// ── 6f/6g. Deferred-start internals (first HandleUpdateTarget callback) ─
/// <summary>
/// Retail <c>MoveToManager::MoveToObject_Internal</c> (<c>0052a400</c>,
/// raw 307597-307663). VERBATIM per decomp §6f: no physics_obj →
/// <see cref="CancelMoveTo"/>(NoPhysicsObject). Snapshot
/// <see cref="SoughtPosition"/>=<paramref name="interpolated"/>,
/// <see cref="CurrentTargetPosition"/>=<paramref name="target"/>. Compute
/// heading toward the INTERPOLATED position (not the raw target — the
/// smoother tracking point) minus current heading, epsilon-snapped/
/// normalized. <see cref="MovementParameters.GetCommand"/> against
/// <see cref="GetCurrentDistance"/> (now valid — CurrentTargetPosition is
/// set) — if motion is needed, queue
/// <c>[TurnToHeading(face interpolated)] → [MoveToPosition]</c> (SAME
/// node plan shape as <see cref="MoveToPosition"/>, but aimed at the
/// interpolated point). If <see cref="MovementParameters.UseFinalHeading"/>
/// — queue a FINAL turn to <c>heading-to-interpolated +
/// DesiredHeading</c> (RELATIVE, unlike <see cref="MoveToPosition"/>'s
/// absolute final heading). Set <see cref="Initialized"/>=true, then
/// <see cref="BeginNextNode"/>.
/// </summary>
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();
}
/// <summary>
/// Retail <c>MoveToManager::TurnToObject_Internal</c> (<c>0052a550</c>,
/// raw 307667-307702). VERBATIM per decomp §6g: no physics_obj →
/// <see cref="CancelMoveTo"/>(NoPhysicsObject). Snapshot
/// <see cref="CurrentTargetPosition"/>=<paramref name="target"/>. Read
/// <see cref="SoughtPosition"/>'s CURRENT heading (with §3d's quirk, this
/// is 0 for a fresh manager — the desired-heading write TurnToObject
/// seeded landed on <see cref="CurrentTargetPosition"/> instead and was
/// overwritten just above). Compute heading-to-target, add the sought
/// heading, <c>fmod 360</c> — the final heading. Write it back into
/// <see cref="SoughtPosition"/>'s frame, queue ONE TurnToHeading node
/// (inlined <c>AddTurnToHeadingNode</c> shape), set
/// <see cref="Initialized"/>=true, <see cref="BeginNextNode"/>. With the
/// §3d quirk, the effective final heading is simply "face the object" —
/// ACE matches verbatim (MoveToManager.cs:246 + 271-276).
/// </summary>
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 ──────────────────────────────────
/// <summary>
/// Retail <c>MoveToManager::_DoMotion</c> (<c>00529010</c>, raw
/// 306351-306364). VERBATIM per decomp §7a: no physics_obj → 0x08
/// (NoPhysicsObject); no motion interpreter → 0x0B
/// (NoMotionInterpreter — modeled as always-present since
/// <see cref="_interp"/> is a required ctor field, so this branch is
/// unreachable in this port; kept in the doc for parity). Calls
/// <see cref="MotionInterpreter.adjust_motion"/> EXPLICITLY (mutates
/// <paramref name="motion"/>/<paramref name="p"/>.Speed in place — e.g.
/// promotes WalkForward+HoldKey.Run → RunForward×runRate) THEN tailcalls
/// <see cref="MotionInterpreter.DoInterpretedMotion(uint,MovementParameters)"/>
/// with the ADJUSTED motion id. THIS IS THE ENTIRE CMotionInterp SEAM —
/// MoveToManager never calls the raw-command <c>DoMotion</c>,
/// <c>set_hold_run</c>, or any raw-state API directly.
/// </summary>
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);
}
/// <summary>
/// Retail <c>MoveToManager::_StopMotion</c> (<c>00529080</c>, raw
/// 306368-306381). Identical shape to <see cref="_DoMotion"/>: calls
/// <see cref="MotionInterpreter.adjust_motion"/> then tailcalls
/// <see cref="MotionInterpreter.StopInterpretedMotion(uint,MovementParameters)"/>
/// with the ADJUSTED motion id.
/// </summary>
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);
}
/// <summary>
/// Retail <c>MoveToManager::CancelMoveTo</c> (<c>00529930</c>, raw
/// 306886-306940). VERBATIM per decomp §7c: no-op when
/// <see cref="MovementTypeState"/> is already Invalid (reentrancy guard —
/// see the class doc's reentrancy invariant). Otherwise: drain
/// <see cref="_pendingActions"/>, <see cref="CleanUp"/>, then
/// StopCompletely. The <paramref name="error"/> 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.
/// </summary>
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();
}
/// <summary>
/// Retail <c>MoveToManager::CleanUp</c> (<c>005295c0</c>, raw
/// 306710-306736). VERBATIM per decomp §7d: build a local params
/// (hold_key copied from <see cref="Params"/>, 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
/// (<see cref="TopLevelObjectId"/> != 0 AND
/// <see cref="MovementTypeState"/> != Invalid) — <see cref="_clearTarget"/>()
/// (the P4 TargetTracker unsubscribe). Finally
/// <see cref="InitializeLocalVariables"/>. Does NOT drain
/// <see cref="_pendingActions"/> — <see cref="CancelMoveTo"/>/
/// <see cref="Destroy"/> do that first.
/// </summary>
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();
}
/// <summary>
/// Retail <c>MoveToManager::CleanUpAndCallWeenie</c> (<c>00529650</c>,
/// raw 306740-306752). Despite the name, contains NO weenie call in this
/// build (decomp §7e — the compiled-out server-side callback; body ≡
/// <see cref="CleanUp"/> + StopCompletely). <see cref="MoveToComplete"/>
/// is a documented CLIENT ADDITION (see its doc) firing here and at
/// <see cref="BeginNextNode"/>'s empty-queue completion, standing in for
/// ACE's server-side <c>OnMoveComplete</c> — do NOT present it as retail
/// behavior.
/// </summary>
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;
}
}
/// <summary>
/// Retail <c>TargetInfo</c> (acclient.h:31591, struct #3482) — the callback
/// payload <see cref="MoveToManager.HandleUpdateTarget"/> consumes AND the wire
/// record the R5 <see cref="TargetManager"/> voyeur system exchanges between
/// hosts (<c>SendVoyeurUpdate</c> → <c>receive_target_update</c> →
/// <see cref="TargetManager.ReceiveUpdate"/>).
///
/// <para>R5 EXTENDED this from the R4 4-field callback shape to the full retail
/// 10-field struct. The extra fields (<see cref="ContextId"/>,
/// <see cref="Radius"/>, <see cref="Quantum"/>,
/// <see cref="InterpolatedHeading"/>, <see cref="Velocity"/>,
/// <see cref="LastUpdateTime"/>) default to zero, so the existing 4-argument
/// <c>new TargetInfo(id, status, tp, ip)</c> call sites (MoveToManager tests,
/// the AP-79 adapter pre-V2) still compile unchanged.
/// <see cref="MoveToManager.HandleUpdateTarget"/> only reads
/// <see cref="ObjectId"/>/<see cref="Status"/>/<see cref="InterpolatedPosition"/>;
/// the extra fields are consumed by the voyeur system.</para>
/// </summary>
/// <param name="ObjectId">Retail <c>object_id</c> — matched against
/// <see cref="MoveToManager.TopLevelObjectId"/>; a mismatch is silently
/// ignored (decomp §6d).</param>
/// <param name="Status">Retail <c>status</c> — see <see cref="TargetStatus"/>.</param>
/// <param name="TargetPosition">Retail <c>target_position</c> — the raw
/// (possibly jittery) target position.</param>
/// <param name="InterpolatedPosition">Retail <c>interpolated_position</c> —
/// the smoothed tracking point <see cref="MoveToManager.MoveToObject_Internal"/>
/// steers toward.</param>
/// <param name="ContextId">Retail <c>context_id</c> — the tracking context
/// (0 = the movement context; <c>CPhysicsObj::HandleUpdateTarget</c> only fans
/// out context 0).</param>
/// <param name="Radius">Retail <c>radius</c> — the voyeur's send-on-move
/// threshold (game units).</param>
/// <param name="Quantum">Retail <c>quantum</c> — the dead-reckoning lookahead
/// horizon (seconds) for <c>GetInterpolatedPosition</c>.</param>
/// <param name="InterpolatedHeading">Retail <c>interpolated_heading</c> —
/// normalized self→target direction (falls back to +Z when degenerate).</param>
/// <param name="Velocity">Retail <c>velocity</c> — the target's velocity at
/// send time.</param>
/// <param name="LastUpdateTime">Retail <c>last_update_time</c> — receipt
/// timestamp (drives the 10 s staleness timeout).</param>
public readonly record struct TargetInfo(
uint ObjectId,
TargetStatus Status,
Position TargetPosition,
Position InterpolatedPosition,
uint ContextId = 0,
float Radius = 0f,
double Quantum = 0.0,
Vector3 InterpolatedHeading = default,
Vector3 Velocity = default,
double LastUpdateTime = 0.0);
/// <summary>
/// Retail <c>TargetStatus</c> (acclient.h:7264). Only <see cref="Ok"/> vs
/// non-<see cref="Ok"/> is behaviorally distinguished by
/// <see cref="MoveToManager.HandleUpdateTarget"/> in this build (decomp §6d)
/// — the other values are carried for parity with the P4 TargetTracker
/// contract (V0-pins.md §P4).
/// </summary>
public enum TargetStatus
{
/// <summary>0 — undefined/uninitialized.</summary>
Undefined = 0,
/// <summary>1 — target resolved and tracked normally.</summary>
Ok = 1,
/// <summary>2 — target left the world (despawned).</summary>
ExitWorld = 2,
/// <summary>3 — target teleported.</summary>
Teleported = 3,
/// <summary>4 — target became contained (picked up / stowed).</summary>
Contained = 4,
/// <summary>5 — target became parented (e.g. mounted/wielded).</summary>
Parented = 5,
/// <summary>6 — tracker timed out without an update.</summary>
TimedOut = 6,
}