acdream/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs
Erik fe6ee877d1 test(physics): measure whether the remote sweep alone tracks surface Z (AD-10 Stage 0)
AD-10 claims the remote slope projection is "relocated" out of the sweep
because "remote bodies don't run a full local transition sweep". That
justification is false at HEAD: RuntimeRemotePhysicsUpdater.Tick calls
PhysicsEngine.ResolveWithTransition with the remote's own body, and that
sweep runs acdream's verbatim port of CTransition::adjust_offset
(0x0050a370, pc:272271-272393) once per sub-step. So the boundary
projection is an EXTRA layer, not a relocation — and whether it is doing
anything the sweep does not is a measurement, never an argument.

This commit builds the fixture for that measurement and changes no
production code.

RuntimeRemoteSteepContactSlideTests' private Harness is extracted to
RemoteRampHarness so the new tests share it instead of cloning ~180 lines.
The extraction is behaviour-preserving; its only additions are the
fixture's own TerrainSurface (so an assertion about "is the body on the
surface" is answered by the surface geometry rather than by
re-implementing what the code under test computed), a SurfaceZ helper, and
a Tick overload that supplies a per-frame body-local root displacement —
the locomotion-cycle push a running remote actually carries. All ten Bug B
tests pass unchanged against it.

RuntimeRemoteSlopeProjectionTests then drives the production tick 30 ticks
down a 31-degree walkable ramp and asserts, on EVERY tick rather than at
the end, that the body's root stays within 5 mm of its settled offset from
the terrain beneath it. A staircase catching up on the final tick would
pass a start/end comparison; 30 unprojected ticks accumulate ~1.8 m.

Sabotage results, all from clean builds (bin/obj deleted), reported in
both directions:

  * Discard the sweep's answer (Body.Position = postIntegratePos instead
    of resolveResult.Position): RED at tick 1, body 0.05999 m off the
    surface. This is the tracking test's discriminating sabotage.
  * Flatten the ramp to gradient 0: RED on the anti-vacuity guard
    (dz = 0.0000 m). That guard exists because the tracking assertion
    passes trivially on flat ground, where Z never has to move.
  * Short-circuit Transition.AdjustOffset to `return offset;`: GREEN.
    Recorded, not hidden — it is the reason the contract's proposed T1
    sabotage was rejected. On terrain the sweep has a SECOND independent
    way to plant Z: ValidateWalkable's push-out re-seats the sphere at its
    natural resting distance from the terrain plane every sub-step.
    Removing the step-down probe as well does not change it either
    (measured). The tests therefore assert the OUTCOME the projection
    exists for, and say in their own doc comments that they are not unit
    tests of adjust_offset and must not be cited as such.

One test the contract asked for is deliberately absent. An uphill
counterpart was written, passed, and was then found VACUOUS: on this
fixture ResolveWithTransition returns ok=False for uphill motion and the
body does not move at all, so it "tracked the surface" by standing still.
That finding is filed separately rather than shipped as a green test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 09:34:56 +02:00

272 lines
9.8 KiB
C#

using System.Numerics;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
namespace AcDream.Runtime.Tests.Physics;
/// <summary>
/// Drives the production <see cref="RuntimeRemotePhysicsUpdater"/> tick over a
/// synthetic single-landblock world whose terrain is a constant-gradient ramp,
/// so every contact plane the sweep reports is a real geometric result rather
/// than a stubbed value.
///
/// <para>Extracted 2026-08-06 from <c>RuntimeRemoteSteepContactSlideTests</c>
/// (where it was a private nested class) so the AD-10 slope-projection tests
/// can build on the same fixture instead of cloning it. Behaviour is
/// unchanged; the only additions are <see cref="Surface"/>,
/// <see cref="SurfaceZ"/>, and the root-motion-driving
/// <see cref="Tick(int, Vector3, float)"/> overload.</para>
/// </summary>
internal sealed class RemoteRampHarness : IDisposable
{
private const uint LandblockId = 0x0101FFFFu;
/// <summary>Local XY the body is placed at, near the landblock centre.</summary>
internal const float StartX = 96f;
/// <summary>Local XY the body is placed at, near the landblock centre.</summary>
internal const float StartY = 96f;
private readonly RuntimeEntityObjectLifetime _lifetime;
private readonly RuntimeEntityRecord _record;
private readonly RuntimeRemotePhysicsUpdater _updater;
internal RemoteMotion Remote { get; }
/// <summary>
/// The exact terrain this fixture published into the engine. Tests read
/// the ground's own geometry from here, so an assertion about "is the body
/// on the surface" is answered by the surface rather than by
/// re-implementing whatever the code under test computed.
/// </summary>
internal TerrainSurface Surface { get; }
internal PhysicsEngine Engine => _lifetime.Physics.Engine;
private RemoteRampHarness(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityRecord record,
RemoteMotion remote,
RuntimeRemotePhysicsUpdater updater,
TerrainSurface surface)
{
_lifetime = lifetime;
_record = record;
Remote = remote;
_updater = updater;
Surface = surface;
}
/// <summary>Terrain height at a world-space XY (the landblock sits at 0,0).</summary>
internal float SurfaceZ(float worldX, float worldY)
=> Surface.SampleZ(worldX, worldY);
/// <summary>Terrain height directly under the body's current XY.</summary>
internal float SurfaceZUnderBody()
=> SurfaceZ(Remote.Body.Position.X, Remote.Body.Position.Y);
/// <summary>Body already resting on the ramp, contact established.</summary>
internal static RemoteRampHarness OnRamp(float gradient)
{
RemoteRampHarness harness = Create(gradient, heightAboveSurface: 0f);
// Retail gains spawn contact from the first gravity frame; the
// stationary-remote settle (SpawnPlacementSettler, #270) compresses
// it. Use the production seam so the fixture starts from exactly
// the state a live spawn would.
SpawnPlacementSettler.TrySettle(
harness._lifetime.Physics.Engine,
harness.Remote.Body,
harness.Remote.Body.Position,
harness.Remote.CellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f,
ObjectInfoState.EdgeSlide,
harness._record.LocalEntityId!.Value,
harness.Remote.Movement.HitGround,
harness.Remote.Motion.LeaveGround);
harness.Remote.Airborne = !harness.Remote.Body.OnWalkable;
return harness;
}
/// <summary>Body suspended above the ramp with no contact at all.</summary>
internal static RemoteRampHarness Airborne(float gradient, float height)
{
RemoteRampHarness harness = Create(gradient, heightAboveSurface: height);
harness.Remote.Body.TransientState &= ~(TransientStateFlags.Contact
| TransientStateFlags.OnWalkable);
harness.Remote.Body.ContactPlaneValid = false;
harness.Remote.Airborne = true;
return harness;
}
private static RemoteRampHarness Create(float gradient, float heightAboveSurface)
{
var lifetime = new RuntimeEntityObjectLifetime();
TerrainSurface surface = Ramp(gradient);
lifetime.Physics.Engine.AddLandblock(
LandblockId,
surface,
Array.Empty<AcDream.Core.Physics.CellSurface>(),
Array.Empty<AcDream.Core.Physics.PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
RuntimeEntityRecord record = lifetime.Entities.AddActive(Spawn());
var body = new PhysicsBody
{
// Retail CPhysicsObj constructor state 0x400C08 @0x00512508
// (EdgeSlide | Lighting | Gravity | ReportCollisions), which
// ACE also sends for every creature (PhysicsGlobals.DefaultState).
State = PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.EdgeSlide,
InWorld = true,
};
var remote = new RemoteMotion(body);
lifetime.Entities.SetPhysicsBody(record, body);
lifetime.Entities.SetRemoteMotion(record, remote);
lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true);
float surfaceZ = surface.SampleZ(StartX, StartY);
body.Position = new Vector3(
StartX,
StartY,
surfaceZ + heightAboveSurface);
body.Orientation = Quaternion.Identity;
remote.CellId = TerrainSurface.ComputeOutdoorCellId(
LandblockId,
StartX,
StartY);
remote.LastServerPos = body.Position;
remote.LastServerPosTime = 1.0;
return new RemoteRampHarness(
lifetime,
record,
remote,
new RuntimeRemotePhysicsUpdater(lifetime.Physics),
surface);
}
/// <summary>Tick with an empty animation root-motion frame.</summary>
internal void Tick(int count, float dt = 1f / 30f)
=> Tick(count, Vector3.Zero, dt);
/// <summary>
/// Tick while <c>CSequence::update</c> reports the given body-local root
/// displacement every frame — the locomotion-cycle push that drives a
/// running remote between server position updates.
/// </summary>
internal void Tick(int count, Vector3 rootMotionLocalPerTick, float dt = 1f / 30f)
{
var frame = new MotionDeltaFrame();
for (int i = 0; i < count; i++)
{
frame.Reset();
frame.Origin = rootMotionLocalPerTick;
_updater.Tick(
_record,
Remote,
objectScale: 1f,
sequencer: null,
dt,
_record.ObjectClockEpoch,
frame,
radius: 0.48f,
height: 1.835f,
liveCenterX: 1,
liveCenterY: 1);
}
}
/// <summary>
/// A constant-gradient ramp. The heightmap byte at (x, y) indexes a table
/// whose entries rise linearly, so every cell of the landblock has the same
/// plane normal and the sampled contact plane is a single constant plane.
/// </summary>
private static TerrainSurface Ramp(float gradient)
{
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i * gradient * TerrainSurface.CellSize;
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(8 - y);
return new TerrainSurface(heights, heightTable);
}
private static WorldSession.EntitySpawn Spawn()
{
var position = new CreateObject.ServerPosition(
LandblockId,
StartX,
StartY,
0f,
1f,
0f,
0f,
0f);
var timestamps = new PhysicsTimestamps(
Position: 1,
Movement: 1,
State: 1,
Vector: 1,
Teleport: 0,
ServerControlledMove: 1,
ForcePosition: 0,
ObjDesc: 1,
Instance: 1);
const uint rawState = (uint)(PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.EdgeSlide);
var physics = new PhysicsSpawnData(
RawState: rawState,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
0x70000101u,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"remote-ramp-fixture",
null,
null,
0x09000001u,
PhysicsState: rawState,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
public void Dispose() => _lifetime.Dispose();
}