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;
///
/// Drives the production 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.
///
/// Extracted 2026-08-06 from RuntimeRemoteSteepContactSlideTests
/// (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 ,
/// , and the root-motion-driving
/// overload.
///
/// ⚠ This ramp's gradient is EXACTLY along Y (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
/// CTransition::adjust_offset annihilates when a sliding normal is
/// live: the crease cross(sliding, contact) 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 for the retail anchors),
/// so an axis-aligned uphill push against this fixture moves nothing, and
/// any assertion written that way passes vacuously. 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.
///
internal sealed class RemoteRampHarness : IDisposable
{
private const uint LandblockId = 0x0101FFFFu;
/// Local XY the body is placed at, near the landblock centre.
internal const float StartX = 96f;
/// Local XY the body is placed at, near the landblock centre.
internal const float StartY = 96f;
private readonly RuntimeEntityObjectLifetime _lifetime;
private readonly RuntimeEntityRecord _record;
private readonly RuntimeRemotePhysicsUpdater _updater;
internal RemoteMotion Remote { get; }
///
/// 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.
///
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;
}
/// Terrain height at a world-space XY (the landblock sits at 0,0).
internal float SurfaceZ(float worldX, float worldY)
=> Surface.SampleZ(worldX, worldY);
/// Terrain height directly under the body's current XY.
internal float SurfaceZUnderBody()
=> SurfaceZ(Remote.Body.Position.X, Remote.Body.Position.Y);
/// Body already resting on the ramp, contact established.
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;
}
/// Body suspended above the ramp with no contact at all.
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(),
Array.Empty(),
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);
}
/// Tick with an empty animation root-motion frame.
internal void Tick(int count, float dt = 1f / 30f)
=> Tick(count, Vector3.Zero, dt);
///
/// Tick while CSequence::update reports the given body-local root
/// displacement every frame — the locomotion-cycle push that drives a
/// running remote between server position updates.
///
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);
}
}
///
/// 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.
///
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(),
Array.Empty(),
Array.Empty(),
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();
}