feat(motion): L.3 M1 — fresh InterpolationManager port + retail spec

Rewrites src/AcDream.Core/Physics/InterpolationManager.cs from the spec
in docs/research/2026-05-04-l3-port/04-interp-manager.md. Public API
preserved (Vector3-returning AdjustOffset, Enqueue, Clear, IsActive,
Count) so PositionManager + GameWindow callers continue to compile;
internals are full retail spec.

Bug fixes vs prior port (audit 04-interp-manager.md § 7):

  #1  progress_quantum accumulates dt (sum of frame deltas), not step
      magnitude. Retail line 353140; the prior port's `+= step` made
      the secondary stall ratio meaningless.

  #3  Far-branch Enqueue (dist > AutonomyBlipDistance = 100m) sets
      _failCount = StallFailCountThreshold + 1 = 4, so the next
      AdjustOffset call's post-stall check fires an immediate blip-to-
      tail snap. Retail line 352944. Prior port silently drifted
      toward far targets at catch-up speed instead of teleporting.

  #4  Secondary stall test ports the retail formula verbatim:
      cumulative / progress_quantum / dt < CREATURE_FAILED_INTERPOLATION_PERCENTAGE.
      Audit notes the units are 1/sec (likely Turbine bug or x87 FPU
      misread by Binary Ninja) — mirrored byte-for-byte regardless.

  #5  Tail-prune is a tail-walking loop, not a single-tail compare.
      Multiple consecutive stale tail entries within DesiredDistance
      (0.05 m) of the new target collapse together. Retail line 352977.

  #6  Cap-eviction at the HEAD when count reaches 20 (already correct
      in the prior port; verified).

New API: Enqueue gains an optional `currentBodyPosition` parameter so
the far-branch detection can reference the body when the queue is
empty. Backward-compatible (default null = pre-far-branch behavior).

UseTime collapsed into AdjustOffset's tail (post-stall blip check)
since acdream has no per-tick UseTime call separate from
adjust_offset; identical semantic outcome.

State fields renamed to retail names with sentinel values:
  _frameCounter, _progressQuantum, _originalDistance (init = 999999f
  sentinel per retail line 0x00555D30 ctor), _failCount.

Tests:
- 17/17 InterpolationManagerTests green.
- New test Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset
  pins the bug #3 fix: enqueueing 150 m away triggers a same-tick
  blip (delta length ≈ 150 m), and the queue clears.

Spec tree: 17 research docs (00–14) under docs/research/2026-05-04-l3-port/.
00-master-plan + 00-port-plan describe the 8-phase rollout. 01-per-tick,
03-up-routing, 04-interp-manager, 05-position-manager-and-partarray,
06-acdream-audit, 14-local-player-audit are the L.3 spec used by this
commit and the M2 follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-05 14:56:42 +02:00
parent a3f53c2644
commit de129bc164
18 changed files with 10721 additions and 190 deletions

View file

@ -7,32 +7,34 @@ namespace AcDream.Core.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// InterpolationManager — retail CPhysicsObj interpolation queue.
//
// Ports:
// CPhysicsObj::InterpolateTo (acclient @ 0x005104F0)
// InterpolationManager::adjust_offset (acclient @ 0x00555D30)
// InterpolationManager::UseTime (acclient @ 0x00555F20) — stall/blip
// Source spec: docs/research/2026-05-04-l3-port/04-interp-manager.md
// Retail addresses (Sept-2013 EoR PDB):
// InterpolationManager::InterpolateTo acclient @ 0x00555B20
// InterpolationManager::adjust_offset acclient @ 0x00555D30
// InterpolationManager::UseTime acclient @ 0x00555F20
// InterpolationManager::NodeCompleted acclient @ 0x005559A0
// InterpolationManager::StopInterpolating acclient @ 0x00555950
//
// FIFO position-waypoint queue (cap 20). On each physics tick the caller
// passes current body position + max-speed from the motion table; we return
// the delta vector to apply to the body for this frame.
// FIFO position-waypoint queue (cap 20). Each physics tick the caller passes
// current body position + max-speed from the motion table; we return the
// world-space delta vector to apply to the body for this frame.
//
// Queue semantics:
// - Head = next target. Body walks toward head at catch-up speed.
// - Tail = most-recent server position. On stall we blip directly to tail
// (retail UseTime @ 0x00555F20: copies tail_ position, calls
// CPhysicsObj::SetPositionSimple, then StopInterpolating).
// Public C# API kept Vector3-based for compatibility with PositionManager and
// GameWindow callsites; retail-spec method names are documented inline. The
// retail Frame mutation pattern collapses to "return a Vector3 delta" because
// adjust_offset's offset Frame is rotation-zero (translation-only) for this
// queue's purposes — see audit 04-interp-manager.md § 4.
//
// Constants verified from named binary at the addresses cited above (not
// guesses):
// MAX_INTERPOLATED_VELOCITY_MOD = 2.0
// MAX_INTERPOLATED_VELOCITY = 7.5
// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters)
// DESIRED_DISTANCE = 0.05
// Bug fixes applied vs prior port (audit § 7):
// #1: progress_quantum accumulates dt (not step magnitude).
// #3: far-branch Enqueue sets node_fail_counter = 4 → immediate next-tick
// blip-to-tail. Triggered by distance > AutonomyBlipDistance (100 m).
// #4: secondary stall test ports the retail formula verbatim:
// cumulative_progress / progress_quantum / dt < 0.30.
// #5: tail-prune is a tail-walking loop (collapses multiple stale entries).
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Waypoint used internally by <see cref="InterpolationManager"/>.
/// </summary>
/// <summary>Internal queue node. type=1 = Position waypoint (only kind we use).</summary>
internal sealed class InterpolationNode
{
public Vector3 TargetPosition;
@ -41,7 +43,7 @@ internal sealed class InterpolationNode
}
/// <summary>
/// Per-remote-entity position interpolation queue. Caller enqueues server
/// Per-remote-entity position interpolation queue. Caller enqueues server
/// position updates and calls <see cref="AdjustOffset"/> once per physics
/// tick to get the per-frame correction delta.
/// </summary>
@ -49,281 +51,339 @@ public sealed class InterpolationManager
{
// ── public constants (retail binary values) ───────────────────────────────
/// <summary>Maximum waypoints held before oldest is dropped.</summary>
/// <summary>Maximum waypoints held before oldest (head) is dropped.</summary>
public const int QueueCap = 20;
/// <summary>
/// Catch-up gain: catchUpSpeed = motionMaxSpeed × this modifier.
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30).
/// Retail MAX_INTERPOLATED_VELOCITY_MOD (@ 0x00555D30 line 353122).
/// </summary>
public const float MaxInterpolatedVelocityMod = 2.0f;
/// <summary>
/// Fallback catch-up speed (m/s) when motion-table max speed is
/// unavailable (zero/tiny).
/// Retail MAX_INTERPOLATED_VELOCITY (@ 0x00555D30).
/// unavailable. Retail MAX_INTERPOLATED_VELOCITY (@ 0x40f00000 line 353137).
/// </summary>
public const float MaxInterpolatedVelocity = 7.5f;
/// <summary>
/// Per-5-frame stall progress threshold (meters). Body must advance at
/// least this far in <see cref="StallCheckFrameInterval"/> frames or
/// the window counts as a stall.
/// Per-5-frame stall progress threshold (meters).
/// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
/// </summary>
public const float MinDistanceToReachPosition = 0.20f;
/// <summary>
/// Reach + duplicate-prune radius (meters). Node is popped when
/// distance to its target falls below this value; new enqueues within
/// this distance of the tail are ignored.
/// Reach + duplicate-prune radius (meters).
/// Retail DESIRED_DISTANCE (@ 0x00555D30).
/// </summary>
public const float DesiredDistance = 0.05f;
/// <summary>
/// Number of ticks between stall progress checks.
/// Number of ticks per stall progress check window.
/// Retail frame_counter threshold (@ 0x00555E14).
/// </summary>
public const int StallCheckFrameInterval = 5;
/// <summary>
/// Minimum fraction of cumulative progress_quantum that counts as "real
/// progress" in a stall check window. Below this fraction the window
/// counts as a stall (secondary check, applies when progress_quantum > 0).
/// Secondary stall ratio threshold — port verbatim from retail.
/// Audit notes the formula has odd units (1/sec); not our bug to fix.
/// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
/// </summary>
public const float StallProgressMinFraction = 0.30f;
/// <summary>
/// Stall-fail counter threshold. The body is blipped to the tail of the
/// queue when <c>node_fail_counter</c> EXCEEDS this value (i.e., on the
/// 4th consecutive failed window, not the 3rd).
/// Retail: <c>node_fail_counter &gt; 3</c> (@ 0x00555F39).
/// Stall-fail counter threshold. Blip fires when fail count EXCEEDS this
/// value (4+, not 3). Retail UseTime check (@ 0x00555F39): fail &gt; 3.
/// </summary>
public const int StallFailCountThreshold = 3;
// ── internals ─────────────────────────────────────────────────────────────
private readonly LinkedList<InterpolationNode> _queue = new();
/// <summary>Frames elapsed since the last 5-frame stall-check window fired.</summary>
private int _framesSinceLastStallCheck = 0;
/// <summary>
/// Distance threshold (meters) above which an Enqueue is treated as a far
/// jump and pre-arms an immediate blip. Retail outdoor value; indoor is
/// 20 m. Bug #3 fix from audit § 7.
/// </summary>
public const float AutonomyBlipDistance = 100.0f;
/// <summary>
/// Cumulative sum of per-frame <c>step</c> magnitudes within the current
/// 5-frame window. Retail <c>progress_quantum</c>.
/// Sentinel for original_distance before the first window baseline is
/// taken. Retail value (@ 0x00555D30 ctor) is 999999f.
/// </summary>
private float _progressQuantum = 0f;
public const float OriginalDistanceSentinel = 999999f;
/// <summary>
/// Distance to the head node recorded at the START of the current
/// 5-frame window. Retail <c>original_distance</c>.
/// </summary>
private float _distanceAtWindowStart = 0f;
private const float FEpsilon = 0.0002f;
/// <summary>
/// True once the first valid distance sample has been taken and
/// <c>_distanceAtWindowStart</c> is populated. Guards against the
/// first-window false-positive that occurs when the field defaults to 0.
/// </summary>
private bool _haveBaselineDistance = false;
// ── internals (retail field names in comments) ────────────────────────────
/// <summary>
/// Number of consecutive 5-frame windows that failed both the absolute
/// and ratio progress checks. Retail <c>node_fail_counter</c>.
/// Blip fires when this EXCEEDS <see cref="StallFailCountThreshold"/>.
/// </summary>
private int _failCount = 0;
private readonly LinkedList<InterpolationNode> _queue = new(); // position_queue
private int _frameCounter = 0; // frame_counter
private float _progressQuantum = 0f; // progress_quantum (sum of dt)
private float _originalDistance = OriginalDistanceSentinel; // original_distance
private int _failCount = 0; // node_fail_counter
// ── public API ────────────────────────────────────────────────────────────
/// <summary>True when the queue holds at least one waypoint.</summary>
public bool IsActive => _queue.Count > 0;
/// <summary>
/// Current waypoint count (visible to the test assembly for cap verification).
/// </summary>
/// <summary>Current waypoint count (visible to tests for cap verification).</summary>
internal int Count => _queue.Count;
/// <summary>
/// Stop interpolating: clear the queue and reset all stall counters.
/// Retail StopInterpolating / destructor cleanup.
/// Stop interpolating: drain queue and reset all stall state to sentinel
/// values. Retail StopInterpolating (@ 0x00555950).
/// </summary>
public void Clear()
{
_queue.Clear();
_framesSinceLastStallCheck = 0;
_progressQuantum = 0f;
_distanceAtWindowStart = 0f;
_haveBaselineDistance = false;
_failCount = 0;
_frameCounter = 0;
_progressQuantum = 0f;
_originalDistance = OriginalDistanceSentinel;
_failCount = 0;
}
/// <summary>
/// Enqueue a new server-authoritative position waypoint.
///
/// <para>
/// Step 1: Duplicate-prune — if the new target is within
/// <see cref="DesiredDistance"/> of the current tail, ignore it.<br/>
/// Step 2: Cap — if the queue is already at <see cref="QueueCap"/>,
/// drop the oldest (head) entry.<br/>
/// Step 3/4: Append a new <see cref="InterpolationNode"/>.
/// </para>
///
/// Retail CPhysicsObj::InterpolateTo (@ 0x005104F0).
/// Enqueue a new server-authoritative waypoint. Implements retail
/// <c>InterpolateTo</c> branching:
/// <list type="bullet">
/// <item><b>Already-close</b>: if distance(body, target) ≤
/// <see cref="DesiredDistance"/>, queue is wiped (StopInterpolating)
/// and no node is enqueued.</item>
/// <item><b>Far</b>: if distance(reference, target) &gt;
/// <see cref="AutonomyBlipDistance"/>, enqueue and set
/// node_fail_counter = StallFailCountThreshold + 1 — pre-arms an
/// immediate blip on the next AdjustOffset call.</item>
/// <item><b>Near</b>: tail-prune loop collapses adjacent stale entries
/// within <see cref="DesiredDistance"/>; cap at 20 (head eviction);
/// enqueue.</item>
/// </list>
/// </summary>
/// <param name="targetPosition">Server-reported world position.</param>
/// <param name="heading">Server-reported heading (radians, AC convention).</param>
/// <param name="isMovingTo">True when the body is in motion — gates heading validity.</param>
public void Enqueue(Vector3 targetPosition, float heading, bool isMovingTo)
/// <param name="heading">Server-reported heading (radians).</param>
/// <param name="isMovingTo">True when body is currently following an MTP.</param>
/// <param name="currentBodyPosition">
/// Body's current world position. Used for the already-close check (versus
/// body) and as the fallback distance reference when the queue is empty.
/// Pass <c>null</c> if not available — far/near classification falls back
/// to "near" (no pre-armed blip).
/// </param>
public void Enqueue(
Vector3 targetPosition,
float heading,
bool isMovingTo,
Vector3? currentBodyPosition = null)
{
// Step 1: duplicate-prune
if (_queue.Last is { } last)
// Retail compares dist against either the tail's stored position
// (if tail exists AND tail->type == 1) or the body's m_position.
Vector3 reference;
bool haveTail = _queue.Last is { } tail;
if (haveTail)
{
if (Vector3.Distance(targetPosition, last.Value.TargetPosition) < DesiredDistance)
return;
reference = _queue.Last!.Value.TargetPosition;
}
else if (currentBodyPosition.HasValue)
{
reference = currentBodyPosition.Value;
}
else
{
reference = targetPosition; // dist = 0 → near branch
}
// Step 2: enforce cap
float dist = Vector3.Distance(reference, targetPosition);
// Far branch (retail line 352918, dist > GetAutonomyBlipDistance):
if (dist > AutonomyBlipDistance)
{
EnqueueRaw(targetPosition, heading, isMovingTo);
// Pre-arm immediate blip on next AdjustOffset (audit § 7 #3).
_failCount = StallFailCountThreshold + 1;
return;
}
// Near & already-close branch (retail line 352962):
// distance(body, target) ≤ DesiredDistance → wipe queue, no enqueue.
if (currentBodyPosition.HasValue)
{
float bodyDist = Vector3.Distance(currentBodyPosition.Value, targetPosition);
if (bodyDist <= DesiredDistance)
{
Clear();
return;
}
}
// Near & not-close branch:
// 1. Tail-prune loop — collapse all consecutive stale tail entries
// within DesiredDistance of the new target (audit § 7 #5).
while (_queue.Last is { } stale &&
Vector3.Distance(stale.Value.TargetPosition, targetPosition) <= DesiredDistance)
{
_queue.RemoveLast();
}
// 2. Cap at 20 — drop head (audit § 7 #6).
if (_queue.Count >= QueueCap)
_queue.RemoveFirst();
// Steps 3+4: add node
var node = new InterpolationNode
// 3. Append.
EnqueueRaw(targetPosition, heading, isMovingTo);
}
private void EnqueueRaw(Vector3 target, float heading, bool isMovingTo)
{
_queue.AddLast(new InterpolationNode
{
TargetPosition = targetPosition,
TargetPosition = target,
Heading = heading,
IsHeadingValid = isMovingTo,
};
_queue.AddLast(node);
});
}
/// <summary>
/// Compute the per-frame position correction delta.
/// Compute the per-frame world-space correction delta. Combines the retail
/// <c>UseTime</c> blip-check (fail_count &gt; 3 → snap to tail, clear queue)
/// with the per-frame <c>adjust_offset</c> step computation.
///
/// <para>
/// Returns <see cref="Vector3.Zero"/> when the queue is empty or when
/// the head node has been reached. Returns a snap delta (tail
/// currentBodyPosition) after <see cref="StallFailCountThreshold"/>
/// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
/// then clears the queue.
/// </para>
/// Returns <see cref="Vector3.Zero"/> when:
/// • queue is empty,
/// • head reached (distance &lt; <see cref="DesiredDistance"/>) — head pops,
/// • dt is invalid (≤ 0 or NaN).
///
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
/// UseTime stall/blip (@ 0x00555F20).
/// Returns the snap delta (tail currentBodyPosition) when fail_count
/// exceeds <see cref="StallFailCountThreshold"/>, then clears the queue.
/// </summary>
/// <param name="dt">Frame delta time (seconds).</param>
/// <param name="currentBodyPosition">Current world-space body position.</param>
/// <param name="maxSpeedFromMinterp">
/// Max motion-table speed for this entity's current cycle (m/s), as
/// reported by MotionInterpreter. Pass 0 if unavailable; the fallback
/// <see cref="MaxInterpolatedVelocity"/> will be used.
/// Max motion-table speed for this entity's current cycle (m/s).
/// Pass 0 to use the <see cref="MaxInterpolatedVelocity"/> fallback.
/// </param>
/// <returns>World-space delta to apply to the body this frame.</returns>
public Vector3 AdjustOffset(double dt, Vector3 currentBodyPosition, float maxSpeedFromMinterp)
{
// Guard: bad dt → skip entirely to prevent NaN poisoning PhysicsBody.Position.
if (dt <= 0 || double.IsNaN(dt)) return Vector3.Zero;
// dt sanity guard — protects PhysicsBody.Position from NaN poisoning.
if (dt <= 0 || double.IsNaN(dt))
return Vector3.Zero;
// Step 1: empty queue → no correction
if (_queue.First is null)
return Vector3.Zero;
// Step 2: peek head
var headNode = _queue.First.Value;
// Distance to head node (retail line 353083).
var head = _queue.First.Value;
float dist = Vector3.Distance(head.TargetPosition, currentBodyPosition);
// Step 3: distance to head target
float dist = (headNode.TargetPosition - currentBodyPosition).Length();
// Step 4: reached node
if (dist < DesiredDistance)
// Reach test (retail line 353089): dist ≤ DESIRED_DISTANCE → pop and
// re-baseline. NodeCompleted(1) advances to next head, also resets the
// window state.
if (dist <= DesiredDistance)
{
_queue.RemoveFirst();
NodeCompleted(popHead: true, currentBodyPosition);
return Vector3.Zero;
}
// Step 5: compute catch-up speed
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
float catchUpSpeed = scaled > 1e-6f ? scaled : MaxInterpolatedVelocity;
// Catch-up speed (retail line 353122 + 353128 fallback).
float scaled = maxSpeedFromMinterp * MaxInterpolatedVelocityMod;
float catchUp = scaled > FEpsilon ? scaled : MaxInterpolatedVelocity;
// Step 6: step magnitude (no overshoot)
float step = catchUpSpeed * (float)dt;
if (step > dist)
step = dist;
// Accumulate progress_quantum (audit § 7 #1: SUM OF DT, not step).
_progressQuantum += (float)dt;
_frameCounter++;
// Step 7: direction × step
Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step;
// Step 8: stall detection (retail adjust_offset @ 0x00555E08-0x00555E92)
//
// Retail tracks two quantities across each 5-frame window:
// progress_quantum — cumulative sum of per-frame step magnitudes
// original_distance — distance to head at the START of the window
//
// At window end (frame_counter >= 5):
// cumulative_progress = original_distance - currentDist
//
// Primary check (@ 0x00555E42):
// cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20 m)
// → window is a stall; increment node_fail_counter.
//
// Secondary check (@ 0x00555E73, only when progress_quantum > 0):
// cumulative_progress / progress_quantum < CREATURE_FAILED_INTERPOLATION_PERCENTAGE (0.30)
// → window is a stall; increment node_fail_counter.
//
// Both checks operate with sticky_object_id == 0 (we never have one).
// Either check failing counts the window as a stall.
//
// Blip fires when node_fail_counter > 3 (retail UseTime @ 0x00555F39).
// Window always resets (frame_counter=0, progress_quantum=0,
// original_distance=currentDist) after the check.
// Initialise window baseline on first call after Clear / new motion.
if (!_haveBaselineDistance)
// 5-frame stall window check (retail line 353146).
if (_frameCounter >= StallCheckFrameInterval)
{
_distanceAtWindowStart = dist;
_haveBaselineDistance = true;
}
float cumulative = _originalDistance - dist;
_progressQuantum += step;
_framesSinceLastStallCheck++;
// Primary check (retail line 353150-353166):
// cumulative >= MIN_DISTANCE_TO_REACH_POSITION (0.20)
bool primaryPass = cumulative >= MinDistanceToReachPosition;
if (_framesSinceLastStallCheck >= StallCheckFrameInterval)
{
float cumulativeProgress = _distanceAtWindowStart - dist;
// Secondary check (retail line 353169-353172, audit § 7 #4):
// cumulative > F_EPSILON
// AND (cumulative / progress_quantum / dt) >= 0.30
//
// Port verbatim despite weird units; audit notes this may be a
// Turbine bug or x87-stack misread by Binary Ninja. Mirroring bytes.
bool secondaryPass = false;
if (cumulative > FEpsilon && _progressQuantum > 0f && dt > 0)
{
float ratio = (cumulative / _progressQuantum) / (float)dt;
secondaryPass = ratio >= StallProgressMinFraction;
}
bool primaryFail = cumulativeProgress < MinDistanceToReachPosition;
bool secondaryFail = _progressQuantum > 0f &&
(cumulativeProgress / _progressQuantum) < StallProgressMinFraction;
if (primaryFail || secondaryFail)
if (!primaryPass && !secondaryPass)
{
_failCount++;
// Blip-to-tail: retail UseTime (@ 0x00555F20) reads
// position_queue.tail_, copies its position to a local,
// calls CPhysicsObj::SetPositionSimple, then
// StopInterpolating. Snap target is the TAIL (the most
// recent server position), not the head.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
Clear();
return tailPos - currentBodyPosition;
}
}
else
{
_failCount = 0;
}
// Reset the 5-frame window regardless of pass/fail.
_framesSinceLastStallCheck = 0;
_progressQuantum = 0f;
_distanceAtWindowStart = dist;
// Re-baseline window regardless of pass/fail.
_frameCounter = 0;
_progressQuantum = 0f;
_originalDistance = dist;
}
else if (_originalDistance >= OriginalDistanceSentinel - 0.5f)
{
// First call after Clear / new motion: seed the baseline so the
// first 5-frame window's cumulative is computed against frame-0
// distance, not the 999999f sentinel. Retail handles this via
// the sentinel itself — the sentinel produces a huge cumulative
// that always passes — but we use a baseline-seeded approach so
// the secondary check has sane progress_quantum behavior.
_originalDistance = dist;
}
// Step 9: return per-frame delta
// Retail UseTime blip check (@ 0x00555F39): fail_count > 3 → snap to
// tail, clear queue. Placed AFTER the stall window logic so it fires
// in the same tick as both:
// (a) the just-incremented fail_count from a stall window pass, AND
// (b) a far-branch Enqueue pre-arm (fail_count = 4 set externally).
// Retail splits this into a separate UseTime call; we collapse it.
if (_failCount > StallFailCountThreshold)
{
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
Clear();
return tailPos - currentBodyPosition;
}
// Per-frame step magnitude (retail line 353218).
float step = catchUp * (float)dt;
// No-overshoot scaling (retail line 353231): if step would overshoot
// dist, clamp to dist.
if (step > dist)
step = dist;
// Direction × step.
Vector3 delta = ((head.TargetPosition - currentBodyPosition) / dist) * step;
return delta;
}
/// <summary>
/// Retail NodeCompleted (@ 0x005559A0). popHead=true after head reached;
/// popHead=false during stall fail (re-baseline only). For our collapsed
/// architecture we always re-baseline on pop.
/// </summary>
private void NodeCompleted(bool popHead, Vector3 currentBodyPosition)
{
_frameCounter = 0;
_progressQuantum = 0f;
if (popHead && _queue.First != null)
{
_queue.RemoveFirst();
}
// Re-baseline on the new head, or reset to sentinel if queue empty.
if (_queue.First is { } newHead)
{
_originalDistance = Vector3.Distance(newHead.Value.TargetPosition, currentBodyPosition);
}
else
{
_originalDistance = OriginalDistanceSentinel;
}
}
}