#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>
287 lines
11 KiB
C#
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();
|
|
}
|