acdream/tests/AcDream.Runtime.Tests/Physics/RemoteRampHarness.cs
Erik ec29a732f5 test(physics): settle #331 — the uphill "refusal" is the #137 anti-parallel absorb, not a defect
#331 reported that `PhysicsEngine.ResolveWithTransition` refuses ALL uphill
motion whenever a `body:` is supplied. It does not. It refuses a step whose
sub-step offset is exactly anti-parallel to a live sliding normal — the
#137-family absorb this project already recorded as retail-faithful.

Measured on the same fixture, same gradient, same body, varying only the
heading relative to the slope gradient:

  (0,     -0.1, 0)  cross-slope 0        -> zero movement, latched
  (0.0001,-0.1, 0)  cross-slope 0.0001 m -> zero movement, latched
  (0.001, -0.1, 0)  cross-slope 0.001 m  -> climbs 0.176 m in 5 ticks
  (0.01,  -0.1, 0)  cross-slope 0.01 m   -> climbs 0.176 m in 5 ticks

The threshold is retail's own F_EPSILON small-offset abort (0.0002 m): about
0.11 degrees off the exact gradient at a 0.1 m step. `RemoteRampHarness`
builds a ramp whose gradient is exactly along Y and the original probe pushed
exactly along -Y, so it hit the measure-zero case with probability 1.

The latch itself is production-real in mechanism — a pure gravity fall under
the production RuntimeRemotePhysicsUpdater, with no fixture settle seam
involved, lands leaving Contact|OnWalkable|Sliding with slidingNormal (0,1,0)
— but every link is faithful to retail, verified in the PDB-paired binary
rather than Binary Ninja (BN typed find_transitional_position `void` and
dropped the load-bearing return value):

  validate_walkable sets collision_normal from the terrain plane when
    OBJECTINFO CONTACT is clear      0x0050d251 / 0x0050d261 / 0x0050d26c
  validate_transition converts it unconditionally  0x0050ac19-0x0050ac30
  set_sliding_normal zeroes Z AND re-normalizes    0x0050a060
  SetPositionInternal persists SLIDING_TS          0x005154c2 / 0x005154e1
  get_object_info re-seeds it next frame           0x00511d44 / 0x00511d4f
  find_transitional_position returns
    `i != 0 && state == OK` on the step-0 abort     0x0050c0ed -> 0x0050c089

ACE agrees (Transition.cs:1027, CollisionInfo.cs:58). No production code
changed; no divergence introduced, so no register row.

What lands is the coverage whose absence made this invisible — nothing in the
suite asserted that a body-bearing mover makes uphill progress on a walkable
slope, and the test that found #331 passed vacuously because the body never
moved:

  RuntimeRemoteUphillProgressTests.ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt
    per-tick climb + surface tracking under a realistic off-gradient heading.
    SAB-A1 AdjustOffset -> Vector3.Zero            reddens at tick 1
    SAB-A2 fixture gradient -> 0 (flat)            reddens at tick 1
  RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal
    characterization pin for the absorb, with the retail anchors inline.
    SAB-B1 delete the get_object_info sliding seed  reddens (climbs to 57.7544)
    SAB-A1                                          reddens
    SAB-A2                                          reddens
    NON-discriminating, measured and documented: making the final tick
    exactly up-slope leaves it green — by then the latch is already cleared.

RemoteRampHarness gains a warning block naming the axis-alignment trap so the
next vacuous uphill assertion is caught at authoring time.

Suite re-measured from a full clean (43 bin/obj removed): 11,198 passed /
4 skipped / 0 failed, against the 11,196/4/0 baseline at 0d62a5ff — exactly
the two tests added.

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

287 lines
11 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>
///
/// <para><b>⚠ This ramp's gradient is EXACTLY along Y</b> (the heightmap
/// varies only with y), so a test that pushes exactly along ±Y is exactly
/// parallel to the slope gradient. That is the measure-zero case retail's
/// <c>CTransition::adjust_offset</c> annihilates when a sliding normal is
/// live: the crease <c>cross(sliding, contact)</c> is the pure cross-slope
/// axis, an exactly-up-slope offset has zero component on it, and the sweep
/// aborts at step 0 leaving the body where it was. A settled or freshly
/// landed body always carries such a normal (the flattened contact plane —
/// see <see cref="RuntimeRemoteUphillProgressTests"/> for the retail anchors),
/// so <b>an axis-aligned uphill push against this fixture moves nothing, and
/// any assertion written that way passes vacuously.</b> This is what #331
/// measured. Push at a realistic off-gradient heading (≥ 0.0002 m of
/// cross-slope component per sub-step, i.e. more than about 0.11° off the
/// gradient at a 0.1 m step) unless the absorb is the thing under test.</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();
}