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>
239 lines
9.1 KiB
C#
239 lines
9.1 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 — 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);
|
||
}
|
||
}
|