Restore the named-retail object update order across local, remote, static, projectile, animation, shadow, teleport, and effect lifetimes. Separate authoritative root commits from spatial rebucketing, preserve per-owner hook/FIFO ordering, and remove update-path allocations with exact lifecycle and residency gates. Add deterministic conformance, adversarial lifetime, GUID-reuse, pending-cell, quaternion, timestamp, and allocation coverage. Release build is warning-free and all 6,446 tests pass with five intentional skips; retail, architecture, and adversarial reviews are clean. Co-authored-by: OpenAI Codex <codex@openai.com>
589 lines
24 KiB
C#
589 lines
24 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using AcDream.Core.Physics.Motion;
|
||
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();
|
||
|
||
[Fact]
|
||
public void AdjustOffset_CompleteFrame_ReplacesOriginAndNonCommutingOrientation()
|
||
{
|
||
var manager = Make();
|
||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
|
||
Quaternion target = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
|
||
manager.Enqueue(
|
||
new Vector3(10f, 0f, 0f),
|
||
target,
|
||
isMovingTo: false,
|
||
currentBodyPosition: Vector3.Zero);
|
||
var offset = new MotionDeltaFrame
|
||
{
|
||
Origin = new Vector3(7f, 8f, 9f),
|
||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.4f),
|
||
};
|
||
|
||
bool overwritten = manager.AdjustOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
currentBodyOrientation: current,
|
||
maxSpeedFromMinterp: 4f,
|
||
offset);
|
||
|
||
Assert.True(overwritten);
|
||
Vector3 expectedLocalOrigin = MoveToMath.GlobalToLocalVec(
|
||
current,
|
||
new Vector3(0.8f, 0f, 0f));
|
||
Assert.Equal(expectedLocalOrigin.X, offset.Origin.X, 5);
|
||
Assert.Equal(expectedLocalOrigin.Y, offset.Origin.Y, 5);
|
||
Assert.Equal(expectedLocalOrigin.Z, offset.Origin.Z, 5);
|
||
Quaternion expectedOrientation = Quaternion.Normalize(
|
||
Quaternion.Inverse(current) * target);
|
||
Assert.InRange(
|
||
MathF.Abs(Quaternion.Dot(
|
||
expectedOrientation,
|
||
Quaternion.Normalize(offset.Orientation))),
|
||
0.99999f,
|
||
1.00001f);
|
||
}
|
||
|
||
[Fact]
|
||
public void AdjustOffset_MoveToNode_KeepsCurrentHeading()
|
||
{
|
||
var manager = Make();
|
||
manager.Enqueue(
|
||
new Vector3(10f, 0f, 0f),
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f),
|
||
isMovingTo: true,
|
||
currentBodyPosition: Vector3.Zero);
|
||
var offset = new MotionDeltaFrame
|
||
{
|
||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.6f),
|
||
};
|
||
|
||
Assert.True(manager.AdjustOffset(
|
||
0.1,
|
||
Vector3.Zero,
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f),
|
||
4f,
|
||
offset));
|
||
|
||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||
}
|
||
|
||
[Fact]
|
||
public void AdjustOffset_UsesManagerWideLatestKeepHeadingFlag()
|
||
{
|
||
var manager = Make();
|
||
Quaternion current = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.2f);
|
||
manager.Enqueue(
|
||
new Vector3(10f, 0f, 0f),
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||
isMovingTo: false,
|
||
currentBodyPosition: Vector3.Zero,
|
||
currentBodyOrientation: current);
|
||
manager.Enqueue(
|
||
new Vector3(20f, 0f, 0f),
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, -0.6f),
|
||
isMovingTo: true,
|
||
currentBodyPosition: Vector3.Zero,
|
||
currentBodyOrientation: current);
|
||
var offset = new MotionDeltaFrame();
|
||
|
||
Assert.True(manager.AdjustOffset(
|
||
0.1,
|
||
Vector3.Zero,
|
||
current,
|
||
4f,
|
||
offset));
|
||
|
||
// keep_heading is an InterpolationManager field in retail, not a
|
||
// property of each queued node. The latest near enqueue therefore
|
||
// controls the current head as well.
|
||
Assert.Equal(Quaternion.Identity, offset.Orientation);
|
||
}
|
||
|
||
[Fact]
|
||
public void AdjustOffset_WithoutContact_LeavesFrameAndQueueUntouched()
|
||
{
|
||
var manager = Make();
|
||
manager.Enqueue(
|
||
FarTarget,
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.7f),
|
||
isMovingTo: false,
|
||
currentBodyPosition: BodyOrigin);
|
||
var offset = new MotionDeltaFrame
|
||
{
|
||
Origin = new Vector3(1f, 2f, 3f),
|
||
Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.4f),
|
||
};
|
||
Vector3 originalOrigin = offset.Origin;
|
||
Quaternion originalOrientation = offset.Orientation;
|
||
|
||
bool overwritten = manager.AdjustOffset(
|
||
0.1,
|
||
BodyOrigin,
|
||
Quaternion.Identity,
|
||
4f,
|
||
offset,
|
||
inContact: false);
|
||
|
||
Assert.False(overwritten);
|
||
Assert.Equal(originalOrigin, offset.Origin);
|
||
Assert.Equal(originalOrientation, offset.Orientation);
|
||
Assert.True(manager.IsActive);
|
||
}
|
||
|
||
[Fact]
|
||
public void Enqueue_AlreadyClose_InstallsOnlyTargetHeading()
|
||
{
|
||
var manager = Make();
|
||
Quaternion target = Quaternion.Normalize(
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitX, 0.8f)
|
||
* Quaternion.CreateFromAxisAngle(Vector3.UnitZ, -0.6f));
|
||
|
||
Quaternion? immediate = manager.Enqueue(
|
||
new Vector3(0.01f, 0f, 0f),
|
||
target,
|
||
isMovingTo: false,
|
||
currentBodyPosition: Vector3.Zero);
|
||
|
||
Assert.NotNull(immediate);
|
||
Quaternion expected = MoveToMath.SetHeading(
|
||
target,
|
||
MoveToMath.GetHeading(target));
|
||
Assert.InRange(
|
||
MathF.Abs(Quaternion.Dot(
|
||
Quaternion.Normalize(expected),
|
||
Quaternion.Normalize(immediate!.Value))),
|
||
0.99999f,
|
||
1.00001f);
|
||
Assert.True(MathF.Abs(Quaternion.Dot(
|
||
Quaternion.Normalize(target),
|
||
Quaternion.Normalize(immediate.Value))) < 0.999f);
|
||
Assert.False(manager.IsActive);
|
||
}
|
||
|
||
// =========================================================================
|
||
// 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);
|
||
}
|
||
}
|