Stage 0's measurement (previous commit) says the projection is redundant,
so AD-10 retires by deletion rather than by narrowing.
The measurement. With the sample forced to null at BOTH fork sites, from a
clean build:
* a remote running 30 ticks down a 31-degree walkable ramp produces a
BIT-IDENTICAL trajectory, position for position;
* on an 8.4-degree ramp the two differ by at most 2.8e-5 m in Z after 30
ticks (0.03 mm) and are identical in X and Y — float ordering noise
from projecting twice against the same plane rather than once;
* the whole AcDream.Runtime.Tests suite is unchanged.
That is what redundancy looks like, and the arithmetic explains it. The
boundary projection and Transition.AdjustOffset are the same operation
(v -= N * dot(v, N)) against the same plane, and the composition is
idempotent: a vector already on the plane has dot(v, N) == 0, so the
sweep's own projection is a no-op on an already-projected offset and the
full-strength projection on an unprojected one. Either alone produces the
same offset. On terrain a THIRD mechanism, ValidateWalkable's push-out,
re-seats the sphere on the plane every sub-step regardless.
Deleted:
* both RuntimeRemotePhysicsUpdater sample sites (the host and no-host
fork branches carried the block verbatim — the AP-22 shape, a row
naming one site where two exist);
* the terrainNormal parameter and projection block on
RemoteMotionCombiner.ComposeOffset;
* the same block on ComputeOffset, which has no production callers but
held a second copy of the divergence, so leaving it would have made
the row's retirement false;
* PhysicsEngine.SampleTerrainNormal, now callerless.
Removing the parameter rather than passing null is deliberate: it is what
makes a future one-site-only regression a compile error instead of a
silent half-fix.
Two tests went with it —
ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope and
its flat-ground twin. Both were weak on their own terms: they drove the
production-dead ComputeOffset and computed their expected values by
re-implementing the projection formula, so they could catch a wrong
MULTIPLY but never a wrong PLANE — which is exactly what the divergence
was. The surviving coverage is geometric and runs the production tick.
Three claims in the old row did not survive contact with the code and are
recorded in the retired row rather than quietly dropped: the justification
(remotes do run the sweep); the description of ComposeOffset's guard as
"interpolation-active" when the code reads `if (!interpolationOverwrote`;
and the roof clause, stale since Bug B gated the sample on OnWalkable —
a steep roof is OnWalkable == false, so the path never ran on #32's
geometry. The retail anchor is corrected too: pc:272296-272346 truncated
both the sliding-normal validity gate at the head and the entire safety
push-out block at the tail. The whole function is 0x0050a370,
pc:272271-272393.
This does not fix #32 and does not partially fix it. #32's remote half was
already closed at 204d0ae0. What deletion does improve is the case #32
never covered: a remote on a WALKABLE non-terrain surface — a bridge, a
dock, a gentle roof, a ramp inside a building — where the terrain sample
returned the plane of the ground far below and applied a wrong plane
rather than none. That surface now gets the body's own committed contact
plane, because that is the only projection left.
The planning contract this work executed is committed alongside as
docs/research/2026-08-06-ad10-contract.md.
Release build 0 errors. Complete solution suite 11,196 passed / 4 skipped
/ 0 failed against the ef976c6d baseline of 11,195 / 4 / 0 — reconciled
exactly as +3 new Runtime tests and -2 deleted Core tests.
Visual gate outstanding: G1 (the ~5 Hz staircase on rolling terrain) is
the veto criterion and runs first; then slope-descent smoothness, a
walkable non-terrain surface, the #32 roof scenario, and flat ground.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
230 lines
9.3 KiB
C#
230 lines
9.3 KiB
C#
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);
|
||
}
|
||
|
||
// =========================================================================
|
||
// AD-10 retired 2026-08-06. Two tests lived here:
|
||
// ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope
|
||
// ComputeOffset_RootMotionFallback_FlatTerrainNormal_NoZChange
|
||
// They exercised the pre-sweep terrain projection that has been deleted, so
|
||
// they are gone with it. Both were also weak on their own terms: they drove
|
||
// ComputeOffset, which has no production callers, and computed their
|
||
// expected values by re-implementing the projection formula in a comment —
|
||
// so they could detect a wrong MULTIPLY but never a wrong PLANE, which is
|
||
// what the divergence actually was. The surviving coverage of the same
|
||
// behaviour is geometric and runs the production tick:
|
||
// AcDream.Runtime.Tests.Physics.RuntimeRemoteSlopeProjectionTests.
|
||
// =========================================================================
|
||
|
||
[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);
|
||
}
|
||
}
|