acdream/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs
Erik 124e046976 fix(runtime): align portal and movement presentation
Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-07-17 08:48:27 +02:00

276 lines
11 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;
// ─────────────────────────────────────────────────────────────────────────────
// RemoteMotionCombinerTests — 6 tests covering ComputeOffset (class renamed R5
// from PositionManager; see RemoteMotionCombiner's class doc).
//
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
// Pure-function combiner: CSequence root-motion delta (rotated by
// body orientation) + InterpolationManager.AdjustOffset correction.
// ─────────────────────────────────────────────────────────────────────────────
public sealed class RemoteMotionCombinerTests
{
// ── helpers ───────────────────────────────────────────────────────────────
private static RemoteMotionCombiner 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,
rootMotionLocalDelta: 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();
// CSequence accumulated (0, 0.4, 0) for this 0.1-second quantum.
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
rootMotionLocalDelta: new Vector3(0f, 0.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,
rootMotionLocalDelta: new Vector3(0f, 0.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,
rootMotionLocalDelta: 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,
rootMotionLocalDelta: new Vector3(0f, 0.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);
// CSequence accumulated rootMotionLocal = (0, 1, 0).
// after Transform by ori → (-1, 0, 0) approximately
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
rootMotionLocalDelta: 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_RootMotionFallback_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,
rootMotionLocalDelta: 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_RootMotionFallback_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,
rootMotionLocalDelta: new Vector3(0f, 0.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);
}
[Fact]
public void ComputeOffset_QueueHeadReached_WithLiteralZeroRootMotion_DoesNotOvershoot()
{
var pm = Make();
var interp = new InterpolationManager();
var target = new Vector3(0.4f, 0f, 0f);
interp.Enqueue(target, heading: 0f, isMovingTo: false);
Vector3 catchUp = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
Vector3 reachedPosition = catchUp;
Vector3 afterReach = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: reachedPosition,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
Assert.Equal(target, reachedPosition);
Assert.Equal(Vector3.Zero, afterReach);
}
}