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), and retail
/// notifies NOTHING on arrival (§4b's empty-queue completion is inline
/// 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 with
/// on NATURAL COMPLETION — the
/// empty-queue exits (both sticky and
/// non-sticky) and 's instant-success
/// path — and NEVER from /plain
/// (a cancel is not an arrival; AD-27's re-send
/// must not fire on user interrupt). 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);
// 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);
}
///
/// 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 (see its doc) firing here and at
/// 's empty-queue completion, 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 AND the wire
/// record the R5 voyeur system exchanges between
/// hosts (SendVoyeurUpdate → receive_target_update →
/// ).
///
/// R5 EXTENDED this from the R4 4-field callback shape to the full retail
/// 10-field struct. The extra fields (,
/// , ,
/// , ,
/// ) default to zero, so the existing 4-argument
/// new TargetInfo(id, status, tp, ip) call sites (MoveToManager tests,
/// the AP-79 adapter pre-V2) still compile unchanged.
/// only reads
/// //;
/// the extra fields are consumed by the voyeur system.
///
/// 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.
/// Retail context_id — the tracking context
/// (0 = the movement context; CPhysicsObj::HandleUpdateTarget only fans
/// out context 0).
/// Retail radius — the voyeur's send-on-move
/// threshold (game units).
/// Retail quantum — the dead-reckoning lookahead
/// horizon (seconds) for GetInterpolatedPosition.
/// Retail interpolated_heading —
/// normalized self→target direction (falls back to +Z when degenerate).
/// Retail velocity — the target's velocity at
/// send time.
/// Retail last_update_time — receipt
/// timestamp (drives the 10 s staleness timeout).
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);
///
/// 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,
}