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); + } }