acdream/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs
Erik 9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00

239 lines
9.1 KiB
C#
Raw 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;
// ─────────────────────────────────────────────────────────────────────────────
// 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 — combined delta
// =========================================================================
[Fact]
public void ComputeOffset_BothActive_Combined()
{
var pm = Make();
var interp = new InterpolationManager();
// Enqueue target 1m ahead on +X
interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
// rootMotion = (0, 4, 0) × 0.1 = (0, 0.4, 0)
// correction ≈ (0.8, 0, 0)
// combined ≈ (0.8, 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: 4f);
Assert.Equal(0.8f, offset.X, precision: 3);
Assert.Equal(0.4f, offset.Y, precision: 3);
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);
}
}