fix(physics): InterpolationManager review findings (L.3.1 Task 1 polish)

Addresses code-quality review findings on commit f43f168:

C-1: Stall detection re-implemented to match retail (acclient lines
353071-353275). Tracks _progressQuantum (sum of step values per window)
+ _distanceAtWindowStart (set at window start). Primary check:
cumulative_progress < MIN_DISTANCE_TO_REACH_POSITION (0.20m absolute).
Secondary check: cumulative_progress / _progressQuantum < 0.30.
Either failing increments fail counter; blip-to-tail at >3 consecutive
fails (already correct).

C-2: Renamed StallFailCountForBlip -> StallFailCountThreshold with
clearer XML doc explaining the > vs >= semantics (blip fires when fail
count EXCEEDS the threshold, i.e. on the 4th consecutive failed window).

I-1: _haveBaselineDistance sentinel prevents first-window false
positive that was triggering spurious fails on every new motion sequence
(old code defaulted _distanceAtWindowStart to 0, making cumulative
progress always negative on frame 5).

I-3: dt <= 0 || NaN guard at AdjustOffset entry prevents NaN
propagation into PhysicsBody.Position.

I-4: Internal field renames for clarity:
  _failFrameCounter        -> _framesSinceLastStallCheck
  _failDistanceLastCheck   -> merged into _distanceAtWindowStart

I-5: Added internal Count property + InternalsVisibleTo (via
AssemblyAttribute in .csproj) so Enqueue_DropsOldestWhenAtCap20
actually verifies cap enforcement. Added assertion that head is the
second-enqueued position after overflow.

3 new tests (AdjustOffset_FirstWindow_DoesNotFalseFail,
AdjustOffset_DtZeroOrNegative_ReturnsZero,
Enqueue_AtCap20_HeadIsSecondOriginal), 16 total. All green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-02 19:10:23 +02:00
parent f43f168916
commit 927636ec77
3 changed files with 213 additions and 56 deletions

View file

@ -11,6 +11,11 @@
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<PackageReference Include="Serilog" Version="4.0.2" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>AcDream.Core.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
</ItemGroup>

View file

@ -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
/// <summary>
/// Per-5-frame stall progress threshold (meters). Body must advance at
/// least this far in <see cref="StallCheckFrameInterval"/> 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).
/// </summary>
public const float MinDistanceToReachPosition = 0.20f;
@ -83,38 +83,69 @@ public sealed class InterpolationManager
/// <summary>
/// Number of ticks between stall progress checks.
/// Retail StallCheckFrameInterval (@ 0x00555D30).
/// Retail frame_counter threshold (@ 0x00555E14).
/// </summary>
public const int StallCheckFrameInterval = 5;
/// <summary>
/// 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).
/// </summary>
public const float StallProgressMinFraction = 0.30f;
/// <summary>
/// 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 <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).
/// </summary>
public const int StallFailCountForBlip = 3;
public const int StallFailCountThreshold = 3;
// ── internals ─────────────────────────────────────────────────────────────
private readonly LinkedList<InterpolationNode> _queue = new();
private int _failFrameCounter = 0;
private float _failDistanceLastCheck = 0f;
private int _failCount = 0;
/// <summary>Frames elapsed since the last 5-frame stall-check window fired.</summary>
private int _framesSinceLastStallCheck = 0;
/// <summary>
/// Cumulative sum of per-frame <c>step</c> magnitudes within the current
/// 5-frame window. Retail <c>progress_quantum</c>.
/// </summary>
private float _progressQuantum = 0f;
/// <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;
/// <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;
/// <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;
// ── 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>
internal int Count => _queue.Count;
/// <summary>
/// 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;
}
/// <summary>
@ -172,8 +205,9 @@ public sealed class InterpolationManager
/// <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="StallFailCountForBlip"/>
/// consecutive stall failures, then clears the queue.
/// currentBodyPosition) after <see cref="StallFailCountThreshold"/>
/// consecutive stall failures (i.e., fail count EXCEEDS the threshold),
/// then clears the queue.
/// </para>
///
/// Retail InterpolationManager::adjust_offset (@ 0x00555D30) +
@ -189,6 +223,9 @@ public sealed class InterpolationManager
/// <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;
// 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