Four tests were asserting pre-change behavior after intentional production changes: #2 BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_SetsCollideb1af56e(L.4, 2026-04-30) added a steep-normal gate in Path 6 that fires BEFORE SetCollide. Airborne sphere hitting steep poly now returns Slid + Collide=false (slide-tangent interim fix). Updated assertion + renamed to ReturnsSlid. #7 PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection #8 DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion235de33(L.5, 2026-04-30) added _physicsAccum accumulator gate: a single Update(1.0f) only integrates one MaxQuantum (0.1s ~ 0.312m at walk speed), not the full 1s. Time is carried in accumulator (not dropped). Fixed both tests to loop Update(MaxQuantum) for ~11 ticks to accumulate >2m of real forward motion, preserving the original distance-threshold assertion intent. #9 PositionManagerTests.ComputeOffset_BothActive_Combined842dfcd(L.3.2, 2026-05-03) changed ComputeOffset from additive (rootMotion + correction) to replace semantics: when AdjustOffset returns non-zero, it REPLACES root motion (retail Frame::operator= semantics). offset.Y = 0 (not 0.4); root motion is dropped when catch-up engages. Updated assertion and renamed to CorrectionReplacesRootMotion. Suite: 9 failures → 5 (only the 5 known-bug tests remain red). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
9.7 KiB
C#
246 lines
9.7 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// PositionManagerTests — 6 tests covering ComputeOffset.
|
||
//
|
||
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
|
||
// Pure-function combiner: animation root motion (seqVel × dt, rotated by
|
||
// body orientation) + InterpolationManager.AdjustOffset correction.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
public sealed class PositionManagerTests
|
||
{
|
||
// ── helpers ───────────────────────────────────────────────────────────────
|
||
|
||
private static PositionManager Make() => new();
|
||
|
||
private static InterpolationManager EmptyInterp() => new();
|
||
|
||
// =========================================================================
|
||
// Test 1: stationary remote — both sources zero, no motion
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_StationaryRemote_BothSourcesZero_NoMotion()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp();
|
||
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: Vector3.Zero,
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 4f);
|
||
|
||
Assert.Equal(Vector3.Zero, offset);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 2: animation only, identity orientation, forward velocity
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_AnimationOnly_Forward_BodyAdvances()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp();
|
||
|
||
// seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0)
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(0f, 4f, 0f),
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 0f);
|
||
|
||
Assert.Equal(0f, offset.X, precision: 4);
|
||
Assert.Equal(0.4f, offset.Y, precision: 4);
|
||
Assert.Equal(0f, offset.Z, precision: 4);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 3: animation only, 180° yaw around Z — body moves south (-Y)
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_AnimationOnly_OrientedSouth_BodyMovesSouth()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp();
|
||
|
||
// 180° around Z flips +Y → -Y
|
||
Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
|
||
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(0f, 4f, 0f),
|
||
ori: ori,
|
||
interp: interp,
|
||
maxSpeed: 0f);
|
||
|
||
Assert.Equal(0f, offset.X, precision: 4);
|
||
Assert.Equal(-0.4f, offset.Y, precision: 4);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 4: interp only, no animation — body chases queue
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_InterpOnly_NoAnimation_BodyChasesQueue()
|
||
{
|
||
var pm = Make();
|
||
var interp = new InterpolationManager();
|
||
|
||
// Enqueue target 1m ahead on +X; body starts at origin
|
||
interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
|
||
|
||
// Expected catch-up: catchUpSpeed = maxSpeed × 2 = 4 × 2 = 8 m/s
|
||
// step = 8 × 0.1 = 0.8m (< dist = 1m so no overshoot clamp)
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: Vector3.Zero,
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 4f);
|
||
|
||
Assert.Equal(0.8f, offset.X, precision: 3);
|
||
Assert.Equal(0f, offset.Y, precision: 3);
|
||
Assert.Equal(0f, offset.Z, precision: 3);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 5: both sources active — correction REPLACES root motion
|
||
//
|
||
// retail-faithful semantics (842dfcd, L.3.2, 2026-05-03):
|
||
// when InterpolationManager.AdjustOffset returns a non-zero correction,
|
||
// ComputeOffset returns the correction alone — it does NOT add root
|
||
// motion on top. Mirrors retail's PositionManager::adjust_offset
|
||
// (acclient @ 0x00555190) which calls Frame::operator= to OVERWRITE
|
||
// the rootOffset frame when catch-up engages.
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_BothActive_CorrectionReplacesRootMotion()
|
||
{
|
||
var pm = Make();
|
||
var interp = new InterpolationManager();
|
||
|
||
// Enqueue target 1m ahead on +X
|
||
interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
|
||
|
||
// correction ≈ (0.8, 0, 0) — replaces root motion (0, 0.4, 0).
|
||
// retail-faithful: correction overwrites root motion, Y is dropped.
|
||
// (842dfcd, 2026-05-03: switched from additive to replace semantics)
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(0f, 4f, 0f),
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 4f);
|
||
|
||
Assert.Equal(0.8f, offset.X, precision: 3);
|
||
Assert.Equal(0f, offset.Y, precision: 3); // root motion dropped — correction replaces
|
||
Assert.Equal(0f, offset.Z, precision: 3);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 6: local-to-world rotation — +90° yaw around Z
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_LocalToWorldRotation_Yaw90()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp();
|
||
|
||
// +90° CCW around Z in right-handed coordinates:
|
||
// body-local +Y → world -X
|
||
Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
|
||
|
||
// seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0)
|
||
// after Transform by ori → (-1, 0, 0) approximately
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 1.0,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(0f, 1f, 0f),
|
||
ori: ori,
|
||
interp: interp,
|
||
maxSpeed: 0f);
|
||
|
||
Assert.Equal(-1f, offset.X, precision: 4);
|
||
Assert.Equal(0f, offset.Y, precision: 4);
|
||
Assert.Equal(0f, offset.Z, precision: 4);
|
||
}
|
||
|
||
// =========================================================================
|
||
// Test 7: slope projection — anim root motion gains Z proportional to slope
|
||
//
|
||
// Lock-the-fix for the "remote running on a slope shows ~5 Hz Z staircase"
|
||
// bug: the queue-empty fallback was returning a flat (Z=0) world motion
|
||
// because animation cycles bake Z=0 in body-local. Projecting onto the
|
||
// local terrain plane gives the motion a Z component matching slope angle
|
||
// × forward speed.
|
||
// =========================================================================
|
||
|
||
[Fact]
|
||
public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp(); // queue empty → fallback path runs
|
||
|
||
// Slope tilted 30° eastward (+X is downhill). Plane normal points
|
||
// up-and-east-of-vertical: (sin 30°, 0, cos 30°) = (0.5, 0, 0.866).
|
||
Vector3 N = Vector3.Normalize(new Vector3(0.5f, 0f, MathF.Sqrt(3f) / 2f));
|
||
|
||
// Body running due east at 4 m/s, dt = 1s → rootMotionWorld initially
|
||
// (4, 0, 0). After projection onto the plane:
|
||
// into = dot((4,0,0), (0.5,0,0.866)) = 2.0
|
||
// result = (4,0,0) - (0.5,0,0.866) * 2.0 = (3.0, 0, -1.732)
|
||
// i.e. body moves east AND descends ~1.73m for the second.
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 1.0,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(4f, 0f, 0f),
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 0f,
|
||
terrainNormal: N);
|
||
|
||
Assert.Equal( 3.000f, offset.X, precision: 3);
|
||
Assert.Equal( 0.000f, offset.Y, precision: 3);
|
||
Assert.Equal(-1.732f, offset.Z, precision: 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
|
||
{
|
||
var pm = Make();
|
||
var interp = EmptyInterp();
|
||
|
||
// Flat ground: normal = +Z. Projection should be a no-op.
|
||
Vector3 offset = pm.ComputeOffset(
|
||
dt: 0.1,
|
||
currentBodyPosition: Vector3.Zero,
|
||
seqVel: new Vector3(0f, 4f, 0f),
|
||
ori: Quaternion.Identity,
|
||
interp: interp,
|
||
maxSpeed: 0f,
|
||
terrainNormal: Vector3.UnitZ);
|
||
|
||
Assert.Equal(0f, offset.X, precision: 4);
|
||
Assert.Equal(0.4f, offset.Y, precision: 4);
|
||
Assert.Equal(0f, offset.Z, precision: 4);
|
||
}
|
||
}
|