diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj
index 2472687..6155c02 100644
--- a/src/AcDream.Core/AcDream.Core.csproj
+++ b/src/AcDream.Core/AcDream.Core.csproj
@@ -11,6 +11,11 @@
+
+
+ <_Parameter1>AcDream.Core.Tests
+
+
diff --git a/src/AcDream.Core/Physics/InterpolationManager.cs b/src/AcDream.Core/Physics/InterpolationManager.cs
index e3a1267..1a6ff53 100644
--- a/src/AcDream.Core/Physics/InterpolationManager.cs
+++ b/src/AcDream.Core/Physics/InterpolationManager.cs
@@ -26,7 +26,7 @@ namespace AcDream.Core.Physics;
// guesses):
// MAX_INTERPOLATED_VELOCITY_MOD = 2.0
// MAX_INTERPOLATED_VELOCITY = 7.5
-// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (unused here — kept for reference)
+// MIN_DISTANCE_TO_REACH_POSITION = 0.20 (absolute stall threshold, meters)
// DESIRED_DISTANCE = 0.05
// ─────────────────────────────────────────────────────────────────────────────
@@ -68,8 +68,8 @@ public sealed class InterpolationManager
///
/// Per-5-frame stall progress threshold (meters). Body must advance at
/// least this far in frames or
- /// it counts as a stall tick.
- /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555D30).
+ /// the window counts as a stall.
+ /// Retail MIN_DISTANCE_TO_REACH_POSITION (@ 0x00555E42).
///
public const float MinDistanceToReachPosition = 0.20f;
@@ -83,38 +83,69 @@ public sealed class InterpolationManager
///
/// Number of ticks between stall progress checks.
- /// Retail StallCheckFrameInterval (@ 0x00555D30).
+ /// Retail frame_counter threshold (@ 0x00555E14).
///
public const int StallCheckFrameInterval = 5;
///
- /// Minimum fraction of the expected advance that counts as "real
- /// progress" in a stall check window. Below this fraction the
- /// fail counter increments.
- /// Retail StallProgressMinFraction (@ 0x00555D30).
+ /// 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).
+ /// Retail CREATURE_FAILED_INTERPOLATION_PERCENTAGE (@ 0x00555E73).
///
public const float StallProgressMinFraction = 0.30f;
///
- /// Number of consecutive stall-check failures before the body is
- /// blipped to the tail of the queue.
- /// Retail StallFailCountForBlip (@ 0x00555D30).
+ /// Stall-fail counter threshold. The body is blipped to the tail of the
+ /// queue when node_fail_counter EXCEEDS this value (i.e., on the
+ /// 4th consecutive failed window, not the 3rd).
+ /// Retail: node_fail_counter > 3 (@ 0x00555F39).
///
- public const int StallFailCountForBlip = 3;
+ public const int StallFailCountThreshold = 3;
// ── internals ─────────────────────────────────────────────────────────────
private readonly LinkedList _queue = new();
- private int _failFrameCounter = 0;
- private float _failDistanceLastCheck = 0f;
- private int _failCount = 0;
+ /// Frames elapsed since the last 5-frame stall-check window fired.
+ private int _framesSinceLastStallCheck = 0;
+
+ ///
+ /// Cumulative sum of per-frame step magnitudes within the current
+ /// 5-frame window. Retail progress_quantum.
+ ///
+ private float _progressQuantum = 0f;
+
+ ///
+ /// Distance to the head node recorded at the START of the current
+ /// 5-frame window. Retail original_distance.
+ ///
+ private float _distanceAtWindowStart = 0f;
+
+ ///
+ /// True once the first valid distance sample has been taken and
+ /// _distanceAtWindowStart is populated. Guards against the
+ /// first-window false-positive that occurs when the field defaults to 0.
+ ///
+ private bool _haveBaselineDistance = false;
+
+ ///
+ /// Number of consecutive 5-frame windows that failed both the absolute
+ /// and ratio progress checks. Retail node_fail_counter.
+ /// Blip fires when this EXCEEDS .
+ ///
+ private int _failCount = 0;
// ── public API ────────────────────────────────────────────────────────────
/// True when the queue holds at least one waypoint.
public bool IsActive => _queue.Count > 0;
+ ///
+ /// Current waypoint count (visible to the test assembly for cap verification).
+ ///
+ internal int Count => _queue.Count;
+
///
/// Stop interpolating: clear the queue and reset all stall counters.
/// Retail StopInterpolating / destructor cleanup.
@@ -122,9 +153,11 @@ public sealed class InterpolationManager
public void Clear()
{
_queue.Clear();
- _failFrameCounter = 0;
- _failDistanceLastCheck = 0f;
- _failCount = 0;
+ _framesSinceLastStallCheck = 0;
+ _progressQuantum = 0f;
+ _distanceAtWindowStart = 0f;
+ _haveBaselineDistance = false;
+ _failCount = 0;
}
///
@@ -172,8 +205,9 @@ public sealed class InterpolationManager
///
/// Returns when the queue is empty or when
/// the head node has been reached. Returns a snap delta (tail −
- /// currentBodyPosition) after
- /// consecutive stall failures, then clears the queue.
+ /// currentBodyPosition) after
+ /// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
+ /// then clears the queue.
///
///
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
@@ -189,6 +223,9 @@ public sealed class InterpolationManager
/// World-space delta to apply to the body this frame.
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;
+
// Step 1: empty queue → no correction
if (_queue.First is null)
return Vector3.Zero;
@@ -218,23 +255,58 @@ public sealed class InterpolationManager
// Step 7: direction × step
Vector3 delta = ((headNode.TargetPosition - currentBodyPosition) / dist) * step;
- // Step 8: stall detection
- _failFrameCounter++;
- if (_failFrameCounter >= StallCheckFrameInterval)
- {
- float progress = _failDistanceLastCheck - dist;
- float expected = catchUpSpeed * (float)dt * StallCheckFrameInterval;
+ // 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.
- if (progress < StallProgressMinFraction * expected)
+ // Initialise window baseline on first call after Clear / new motion.
+ if (!_haveBaselineDistance)
+ {
+ _distanceAtWindowStart = dist;
+ _haveBaselineDistance = true;
+ }
+
+ _progressQuantum += step;
+ _framesSinceLastStallCheck++;
+
+ if (_framesSinceLastStallCheck >= StallCheckFrameInterval)
+ {
+ float cumulativeProgress = _distanceAtWindowStart - dist;
+
+ bool primaryFail = cumulativeProgress < MinDistanceToReachPosition;
+ bool secondaryFail = _progressQuantum > 0f &&
+ (cumulativeProgress / _progressQuantum) < StallProgressMinFraction;
+
+ if (primaryFail || secondaryFail)
{
_failCount++;
- if (_failCount > StallFailCountForBlip)
+ // 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)
{
- // 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.
Vector3 tailPos = _queue.Last!.Value.TargetPosition;
Clear();
return tailPos - currentBodyPosition;
@@ -245,8 +317,10 @@ public sealed class InterpolationManager
_failCount = 0;
}
- _failDistanceLastCheck = dist;
- _failFrameCounter = 0;
+ // Reset the 5-frame window regardless of pass/fail.
+ _framesSinceLastStallCheck = 0;
+ _progressQuantum = 0f;
+ _distanceAtWindowStart = dist;
}
// Step 9: return per-frame delta
diff --git a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs
index eb8654e..fd23931 100644
--- a/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs
@@ -54,26 +54,50 @@ public sealed class InterpolationManagerTests
mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true);
}
- // The next enqueue must NOT reject the entry; instead it drops the oldest.
- // After the insert the queue count must still be QueueCap (not QueueCap+1).
+ // Sanity: queue is at cap before the 21st enqueue.
+ Assert.Equal(InterpolationManager.QueueCap, mgr.Count);
+
+ // The 21st enqueue must drop the oldest (x=0) and keep the count at cap.
mgr.Enqueue(new Vector3(100f, 0f, 0f), heading: 0f, isMovingTo: true);
- // We can't query Count directly (it's internal), but IsActive must remain
- // true, and we verify the cap behaviour indirectly by confirming the call
- // did not throw (the queue is bounded) and the manager is still active.
- Assert.True(mgr.IsActive);
+ // Count must still be QueueCap — not QueueCap+1.
+ Assert.Equal(InterpolationManager.QueueCap, mgr.Count);
- // Drive the body toward the head until the queue empties, counting pops.
- // If the cap was honoured (count stayed at QueueCap after the 21st push)
- // the head should be position x=1 (the 2nd element) rather than x=0 (the
- // original first, which was dropped).
- //
- // We verify this by snapping the body right onto the FarTarget step and
- // counting how many AdjustOffset calls return zero after reaching a node.
- //
- // Simpler: just confirm the queue can be cleared completely.
- mgr.Clear();
- Assert.False(mgr.IsActive);
+ // The head (oldest surviving node) must now be x=1 (the second-original
+ // position), not x=0 (which was dropped). Verify by driving the body
+ // to exactly x=1 — AdjustOffset must pop that node (distance < DesiredDistance)
+ // and return zero, confirming x=1 is the head.
+ var bodyAtSecondOriginal = new Vector3(1f, 0f, 0f);
+ var result = mgr.AdjustOffset(
+ dt: 0.016,
+ currentBodyPosition: bodyAtSecondOriginal,
+ maxSpeedFromMinterp: 10f);
+
+ // Reached head (dist ≈ 0) → zero delta + node popped.
+ Assert.Equal(Vector3.Zero, result);
+ // One node was consumed; count must now be QueueCap - 1.
+ Assert.Equal(InterpolationManager.QueueCap - 1, mgr.Count);
+ }
+
+ [Fact]
+ public void Enqueue_AtCap20_HeadIsSecondOriginal()
+ {
+ // Complementary test for the cap overflow: after 21 enqueues the
+ // second-enqueued position (x=1) must be at the head, not x=0.
+ var mgr = Make();
+ for (int i = 0; i < InterpolationManager.QueueCap; i++)
+ {
+ mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true);
+ }
+ mgr.Enqueue(new Vector3(100f, 0f, 0f), heading: 0f, isMovingTo: true);
+
+ // Place the body far away from x=0 but RIGHT on x=1. If x=0 were the
+ // head the result would be non-zero (body is 1 m away from x=0).
+ // If x=1 is the head the distance is 0 → pop → zero return.
+ var bodyAtX1 = new Vector3(1f, 0f, 0f);
+ var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: bodyAtX1, maxSpeedFromMinterp: 10f);
+
+ Assert.Equal(Vector3.Zero, delta);
}
[Fact]
@@ -218,7 +242,7 @@ public sealed class InterpolationManagerTests
{
// Body stays at origin every frame — zero real progress.
// After 5 frames the stall check fires and _failCount increments (to 1).
- // Queue must still be alive (blip only at > StallFailCountForBlip = 3).
+ // Queue must still be alive (blip only at > StallFailCountThreshold = 3).
var mgr = Make();
var target = new Vector3(50f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
@@ -228,7 +252,7 @@ public sealed class InterpolationManagerTests
mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
}
- // 1 fail < StallFailCountForBlip (3), so queue is still active.
+ // 1 fail < StallFailCountThreshold (3), so queue is still active.
Assert.True(mgr.IsActive);
}
@@ -264,7 +288,7 @@ public sealed class InterpolationManagerTests
[Fact]
public void AdjustOffset_3FailsTriggersBlipToTail()
{
- // Need > StallFailCountForBlip (3) failures.
+ // Need > StallFailCountThreshold (3) failures.
// Each failure requires one stall-check window (5 frames of no progress).
// So we need 4 × 5 = 20 frames with the body frozen at origin.
//
@@ -279,7 +303,7 @@ public sealed class InterpolationManagerTests
// 4 stall-check windows × 5 frames each = 20 frames, body never moves.
Vector3? blipDelta = null;
- const int totalFrames = (InterpolationManager.StallFailCountForBlip + 1)
+ const int totalFrames = (InterpolationManager.StallFailCountThreshold + 1)
* InterpolationManager.StallCheckFrameInterval;
for (int i = 0; i < totalFrames; i++)
@@ -303,4 +327,58 @@ public sealed class InterpolationManagerTests
// Queue must be cleared after blip (retail StopInterpolating).
Assert.False(mgr.IsActive);
}
+
+ // =========================================================================
+ // New tests: I-1 first-window false-positive guard, I-3 dt guard, I-5 cap
+ // =========================================================================
+
+ [Fact]
+ public void AdjustOffset_FirstWindow_DoesNotFalseFail()
+ {
+ // Before the fix, _distanceAtWindowStart defaulted to 0, so on the
+ // first 5-frame window cumulative_progress = 0 - dist = -dist < 0.20
+ // → every new motion sequence triggered a spurious stall fail.
+ //
+ // After the fix, the baseline is seeded from the first call, so
+ // cumulative_progress = dist(frame0) - dist(frame4) which for a body
+ // that hasn't moved yet is ≈ 0. That is still < MIN (0.20), but the
+ // _failCount starts at 0 and must be > 3 (not == 1) to blip. The key
+ // assertion is that after exactly ONE stall window the queue is still
+ // alive (fail count == 1, blip requires > 3).
+ var mgr = Make();
+ mgr.Enqueue(new Vector3(50f, 0f, 0f), heading: 0f, isMovingTo: true);
+
+ // Run exactly one check-window (5 frames) with the body frozen.
+ for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++)
+ {
+ mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
+ }
+
+ // One window fail → _failCount == 1, far below StallFailCountThreshold (3).
+ // Queue must still be active; no spurious blip on first window.
+ Assert.True(mgr.IsActive,
+ "First stall window must NOT trigger a blip (would require > 3 consecutive failures).");
+ }
+
+ [Fact]
+ public void AdjustOffset_DtZeroOrNegative_ReturnsZero()
+ {
+ var mgr = Make();
+ mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true);
+
+ // dt == 0 → guard fires, return zero, no side-effects.
+ var deltaZero = mgr.AdjustOffset(dt: 0.0, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
+ Assert.Equal(Vector3.Zero, deltaZero);
+
+ // dt < 0 → guard fires, return zero.
+ var deltaNeg = mgr.AdjustOffset(dt: -1.0, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
+ Assert.Equal(Vector3.Zero, deltaNeg);
+
+ // dt = NaN → guard fires, return zero.
+ var deltaNaN = mgr.AdjustOffset(dt: double.NaN, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
+ Assert.Equal(Vector3.Zero, deltaNaN);
+
+ // Queue must still be intact (guards did not consume or corrupt state).
+ Assert.True(mgr.IsActive);
+ }
}