acdream/tests/AcDream.Core.Tests/Physics/InterpolationManagerTests.cs
Erik de129bc164 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>
2026-05-05 14:56:42 +02:00

430 lines
18 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
// ─────────────────────────────────────────────────────────────────────────────
// InterpolationManagerTests — covers the retail CPhysicsObj interpolation
// queue port (L.3.1 Task 1).
//
// Source addresses tested:
// CPhysicsObj::InterpolateTo acclient @ 0x005104F0 (Enqueue)
// InterpolationManager::adjust_offset acclient @ 0x00555D30 (AdjustOffset)
// InterpolationManager::UseTime acclient @ 0x00555F20 (blip-to-tail)
// ─────────────────────────────────────────────────────────────────────────────
public sealed class InterpolationManagerTests
{
// ── helpers ───────────────────────────────────────────────────────────────
/// <summary>Origin used as the "body is here" position in most tests.</summary>
private static readonly Vector3 BodyOrigin = Vector3.Zero;
/// <summary>A position clearly outside DesiredDistance (= 0.05 m).</summary>
private static readonly Vector3 FarTarget = new Vector3(10f, 0f, 0f);
private static InterpolationManager Make() => new InterpolationManager();
// =========================================================================
// Queue mechanics
// =========================================================================
[Fact]
public void Enqueue_AddsNode_QueueBecomesActive()
{
var mgr = Make();
Assert.False(mgr.IsActive);
mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true);
Assert.True(mgr.IsActive);
}
[Fact]
public void Enqueue_DropsOldestWhenAtCap20()
{
var mgr = Make();
// Fill the queue to cap with distinct positions spaced far enough
// apart to avoid the duplicate-prune threshold (DesiredDistance = 0.05).
for (int i = 0; i < InterpolationManager.QueueCap; i++)
{
mgr.Enqueue(new Vector3(i * 1f, 0f, 0f), heading: 0f, isMovingTo: true);
}
// 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);
// Count must still be QueueCap — not QueueCap+1.
Assert.Equal(InterpolationManager.QueueCap, mgr.Count);
// 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]
public void Enqueue_PrunesDuplicateWithinDesiredDistance()
{
var mgr = Make();
var basePos = new Vector3(5f, 0f, 0f);
mgr.Enqueue(basePos, heading: 0f, isMovingTo: true);
// Within DesiredDistance (0.05) — must be ignored.
var nearDuplicate = basePos + new Vector3(0.01f, 0f, 0f);
mgr.Enqueue(nearDuplicate, heading: 0f, isMovingTo: true);
// Confirm duplicate was not added: driving the body to basePos should
// exhaust the queue in one pop, leaving it empty.
// Position body exactly AT the target so AdjustOffset pops the head node.
var result = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: basePos, maxSpeedFromMinterp: 10f);
Assert.Equal(Vector3.Zero, result); // reached → pop
Assert.False(mgr.IsActive); // only one node existed
}
[Fact]
public void Clear_EmptiesQueueAndResetsCounters()
{
var mgr = Make();
mgr.Enqueue(FarTarget, heading: 0f, isMovingTo: true);
Assert.True(mgr.IsActive);
mgr.Clear();
Assert.False(mgr.IsActive);
// After Clear, AdjustOffset must return zero (no stale state).
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
Assert.Equal(Vector3.Zero, delta);
}
// =========================================================================
// AdjustOffset math
// =========================================================================
[Fact]
public void AdjustOffset_EmptyQueue_ReturnsZero()
{
var mgr = Make();
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
Assert.Equal(Vector3.Zero, delta);
}
[Fact]
public void AdjustOffset_ReachesNodeWithinDesiredDistance_PopsHead()
{
var mgr = Make();
var target = new Vector3(0.02f, 0f, 0f); // within DesiredDistance (0.05)
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
// Body is at origin; distance = 0.02 < 0.05 → should pop and return zero.
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
Assert.Equal(Vector3.Zero, delta);
Assert.False(mgr.IsActive, "Head node should have been popped after being reached");
}
[Fact]
public void AdjustOffset_ClampedToCatchUpSpeed_2xMotionMax()
{
var mgr = Make();
float maxSpeed = 4.0f; // motion-table max speed
double dt = 0.5; // large dt to make the math clear
// target is far enough that there's no overshoot clamping
var target = new Vector3(100f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: maxSpeed);
// Expected step = catchUpSpeed * dt = (maxSpeed * 2.0) * dt = 4.0
float expectedStep = maxSpeed * InterpolationManager.MaxInterpolatedVelocityMod * (float)dt;
Assert.Equal(expectedStep, delta.Length(), precision: 4);
}
[Fact]
public void AdjustOffset_FallbackSpeed_WhenMinterpZero()
{
var mgr = Make();
double dt = 0.5;
var target = new Vector3(100f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
// maxSpeedFromMinterp = 0 → fallback to MaxInterpolatedVelocity (7.5)
var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 0f);
float expectedStep = InterpolationManager.MaxInterpolatedVelocity * (float)dt;
Assert.Equal(expectedStep, delta.Length(), precision: 4);
}
[Fact]
public void AdjustOffset_OvershootProtection_StepClampedToDistance()
{
var mgr = Make();
float maxSpeed = 10f;
double dt = 1.0; // step = 2*10*1.0 = 20 >> actual distance
// Place target just 0.5 m away — inside the step distance.
var target = new Vector3(0.5f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
var delta = mgr.AdjustOffset(dt, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: maxSpeed);
// Step should be clamped to dist (0.5), not the unclamped 20.
Assert.Equal(0.5f, delta.Length(), precision: 4);
}
// =========================================================================
// Stall detection
// =========================================================================
[Fact]
public void AdjustOffset_StallCounterIncrementsEachFrame()
{
// Run 4 frames (< StallCheckFrameInterval = 5) with a body that does
// not move — the queue should still be active (no blip yet).
var mgr = Make();
var target = new Vector3(10f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
// Body does NOT move — we pass the same fixed position each frame.
for (int i = 0; i < 4; i++)
{
mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
}
// After 4 frames (<5) the stall check hasn't fired yet, queue intact.
Assert.True(mgr.IsActive);
}
[Fact]
public void AdjustOffset_NoProgressMarksFail_AfterFiveFrames()
{
// 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 > StallFailCountThreshold = 3).
var mgr = Make();
var target = new Vector3(50f, 0f, 0f);
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++)
{
mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
}
// 1 fail < StallFailCountThreshold (3), so queue is still active.
Assert.True(mgr.IsActive);
}
[Fact]
public void AdjustOffset_GoodProgressResetsFailCount()
{
// Simulate: body truly advances toward target each frame.
// After each check-interval the fail counter should reset to 0
// (because progress ≥ 30% of expected).
var mgr = Make();
var origin = Vector3.Zero;
var target = new Vector3(50f, 0f, 0f);
float maxSpd = 4f;
double dt = 0.016;
mgr.Enqueue(target, heading: 0f, isMovingTo: true);
// Run 5 frames, advancing the body by the actual delta returned each time.
Vector3 bodyPos = origin;
for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++)
{
var delta = mgr.AdjustOffset(dt, currentBodyPosition: bodyPos, maxSpeedFromMinterp: maxSpd);
bodyPos += delta; // body truly moves
}
// After 5 frames of genuine progress, queue must still be active
// (no blip) and _failCount should have been reset to 0 (no way to read
// it directly, but we verify indirectly: we'd need 3×5=15 more frames
// of stalling to blip — a further 5-frame no-progress window at this
// point should only bring _failCount to 1, not trigger a blip).
Assert.True(mgr.IsActive);
}
[Fact]
public void AdjustOffset_3FailsTriggersBlipToTail()
{
// 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.
//
// Also enqueue a SECOND node (the tail) different from the first, so we
// can verify the snap is to the tail, not the head.
var mgr = Make();
var head = new Vector3(10f, 0f, 0f);
var tail = new Vector3(30f, 0f, 0f);
mgr.Enqueue(head, heading: 0f, isMovingTo: true);
mgr.Enqueue(tail, heading: 0f, isMovingTo: true);
// 4 stall-check windows × 5 frames each = 20 frames, body never moves.
Vector3? blipDelta = null;
const int totalFrames = (InterpolationManager.StallFailCountThreshold + 1)
* InterpolationManager.StallCheckFrameInterval;
for (int i = 0; i < totalFrames; i++)
{
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
if (delta.Length() > 1f) // blip delta will be >> normal per-frame step
{
blipDelta = delta;
break;
}
}
// Blip must have fired.
Assert.NotNull(blipDelta);
// Blip delta = tailPos currentBodyPosition = (30,0,0) (0,0,0)
Assert.Equal(tail.X, blipDelta!.Value.X, precision: 4);
Assert.Equal(tail.Y, blipDelta!.Value.Y, precision: 4);
Assert.Equal(tail.Z, blipDelta!.Value.Z, precision: 4);
// 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).");
}
// =========================================================================
// Far-branch enqueue: when the new target is beyond AutonomyBlipDistance
// (100 m outdoor) of the reference (tail or body), retail
// InterpolationManager::InterpolateTo (acclient @ 0x00555B20 line 352944)
// sets node_fail_counter = 4 so the very next stall-check blips to the
// tail. Audit 04-interp-manager.md § 7 gap #3.
//
// Effect: the body teleports to the freshly-enqueued tail on the first
// adjust_offset call after a far enqueue, instead of drifting toward it
// at catch-up speed. Critical for >100 m server-side teleports / cell
// crossings on observed remotes.
// =========================================================================
[Fact]
public void Enqueue_FarBranch_PrearmsImmediateBlipOnNextAdjustOffset()
{
var mgr = Make();
// Target > AutonomyBlipDistance (100 m) from origin → far branch.
var farTarget = new Vector3(150f, 0f, 0f);
mgr.Enqueue(farTarget, heading: 0f, isMovingTo: true, currentBodyPosition: BodyOrigin);
// Single AdjustOffset call: body still at origin, queue has 1 node,
// node_fail_counter = 4 (set by far-branch enqueue) > 3 threshold,
// so the very first stall-check fires a blip to the tail.
//
// The blip delta should be the full far distance (≈150 m), not a
// single per-frame catch-up step.
Vector3? blipDelta = null;
for (int i = 0; i < InterpolationManager.StallCheckFrameInterval; i++)
{
var delta = mgr.AdjustOffset(dt: 0.016, currentBodyPosition: BodyOrigin, maxSpeedFromMinterp: 4f);
// Blip fires when delta >> per-frame step. Per-frame step at
// 4 m/s × 2 (mod) × 0.016 s = 0.128 m. Blip is 150 m.
if (delta.Length() > 50f)
{
blipDelta = delta;
break;
}
}
Assert.NotNull(blipDelta);
Assert.Equal(150f, blipDelta!.Value.X, precision: 4);
Assert.False(mgr.IsActive, "Queue must be cleared after blip.");
}
[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);
}
}