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(); }