acdream/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs
Erik 204d0ae047 fix(physics): remote bodies slide on steep faces instead of freezing (#32)
A remote observed in acdream landed on a sloped roof and froze; the server slid
on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible
blip. Live probe capture, two adjacent ticks 63 ms apart:

  t=88420671  rsInContact=True rsOnWalkable=False rsIsOnGround=True
              bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True
              vel=(2.146,2.264,-3.549)
  t=88420734  contact=True onWalkable=True   <- forced against the sweep
              gravity=False                   <- cleared
              velBeforeZero=(2.146,2.264,0.000)
              moved=0.0000                    <- and every tick after

The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier
was CORRECT and was then overruled. Four independent links each froze the body
on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero,
a Gravity clear at landing, and a landing edge testing IsOnGround
(= inContact || ...) instead of OnWalkable. The tick called
HandleAllCollisions alone — the tail of SetPositionInternal without its prefix.

Retail simulates remotes locally and derives these bits rather than asserting
them: CPhysics::UseTime @0x00509950 iterates the whole object table;
update_object @0x00515D10 gates only on parent/cell/FROZEN with no
is_player fork; SetPositionInternal @0x00515330 sets CONTACT from
contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs
floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE;
set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround
@0x00511346 edge-triggered with no ownership gate; calc_acceleration
@0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B;
calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear.
acdream had copied retail's airborne no-op WITHOUT retail's local simulation.

The fix is mostly deletion: stop forging the transients, stop discarding the
authoritative velocity, stop clearing Gravity, and route the remote tick
through the same SetPositionInternal commit TickHidden and the local player
already use, with the landing edge derived from the sweep's own OnWalkable.
AP-87's threshold and conditions and InterpolationManager's node_fail_counter
snap-to-tail are deliberately untouched — this removes the CAUSE of the
divergence rather than weakening the backstop.

Cross-checked against ACE: its only creature-side VectorUpdate emitters are the
jump broadcast and spell projectiles, so integrating the wire velocity cannot
double-move a walking remote; and PhysicsGlobals.DefaultState already carries
Gravity, so deleting the manufactured State |= Gravity is safe.

Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated,
AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed
(the two routing gates select snap-vs-interpolate on walkability where retail
uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1
@0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates
at Body.InContact", NOT "re-derive Airborne", which would perturb five writers
and collide with a pinned RemoteTeleportPlacementTests assertion.

Three gaps recorded in #32 rather than papered over: the new LeaveGround
dispatch is untested for chatter; a persistently !Ok transition can latch a
remote airborne; and — the visual-gate watch item — the deleted forge was a
blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0
silently refuses action animations without both, which is the literal root
cause of closed #270. Retail-correct on a steep face, a regression anywhere
else.

10 discriminating tests over a real PhysicsEngine landblock whose contact
normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship.
Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary
ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that
produced the capture above; strip with the family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:21:16 +02:00

518 lines
20 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>
/// Bug B (2026-08-04) — remote characters froze on steep surfaces instead of
/// sliding. The per-tick remote owner forged retail's two contact transients
/// (<c>Contact | OnWalkable</c>) before every sweep, discarded the
/// authoritative velocity, decided its landing edge from the contact-derived
/// <c>ResolveResult.IsOnGround</c> rather than the plane-derived
/// <c>OnWalkable</c>, and cleared the persistent Gravity state bit.
///
/// <para>
/// Retail derives all of it: <c>CPhysicsObj::SetPositionInternal</c>
/// (<c>0x00515330</c>) writes CONTACT_TS from
/// <c>collision_info.contact_plane_valid</c> (0x00515430) and then routes
/// ON_WALKABLE_TS through <c>set_on_walkable</c> (<c>0x00511310</c>) purely on
/// <c>contact_plane.N.z &lt; PhysicsGlobals::floor_z</c>
/// (0x00515465-0x0051548E). <c>set_on_walkable</c> is the SOLE source of
/// <c>MovementManager::HitGround</c>/<c>::LeaveGround</c>. Gravity survives a
/// steep contact because <c>calc_acceleration</c> (<c>0x00510950</c>) only
/// zeroes acceleration when CONTACT and ON_WALKABLE are BOTH set, and
/// <c>calc_friction</c> (<c>0x0050EE70</c>) returns at its first line when
/// ON_WALKABLE is clear.
/// </para>
///
/// <para>
/// Every test here runs the production <see cref="RuntimeRemotePhysicsUpdater"/>
/// tick over a synthetic landblock whose terrain is a single constant-gradient
/// ramp, so the contact plane the sweep finds is a real geometric result, not a
/// stubbed value.
/// </para>
/// </summary>
public sealed class RuntimeRemoteSteepContactSlideTests
{
/// <summary>
/// Ramp gradient chosen so the terrain-plane normal's Z lands just under
/// retail's walkable limit — 52.4 degrees against a 48.4-degree limit, the
/// same relationship as the live house roof that produced the freeze
/// (measured contact-plane Normal.Z 0.6097 versus FloorZ 0.6642).
/// </summary>
private const float SteepGradient = 1.30f;
/// <summary>A gentle ramp that is comfortably walkable.</summary>
private const float WalkableGradient = 0.10f;
[Fact]
public void SteepTerrainProducesANonWalkableContactPlane()
{
using Harness harness = Harness.OnRamp(SteepGradient);
Assert.True(harness.Remote.Body.ContactPlaneValid);
Assert.InRange(
harness.Remote.Body.ContactPlane.Normal.Z,
0.55f,
PhysicsGlobals.FloorZ - 0.001f);
}
/// <summary>
/// The landing edge must be the sweep's plane-derived
/// <c>OnWalkable</c>, never <c>IsOnGround</c> (which is
/// <c>inContact || …</c> and is therefore TRUE on a steep contact).
/// </summary>
[Fact]
public void SteepContactDoesNotLatchALanding()
{
using Harness harness = Harness.OnRamp(SteepGradient);
harness.Remote.Airborne = true;
int groundEdges = 0;
harness.Remote.Motion.RemoveLinkAnimations = () => groundEdges++;
harness.Tick(40);
Assert.True(harness.Remote.Body.InContact);
Assert.False(harness.Remote.Body.OnWalkable);
Assert.Equal(0, groundEdges);
}
/// <summary>
/// Gravity is a persistent object property in retail; nothing on a ground
/// edge may clear it. Before the fix both landing blocks did, which is why
/// <c>calc_acceleration</c> returned zero forever afterwards.
/// </summary>
[Fact]
public void GravityPersistsAcrossTicksOnASteepContact()
{
using Harness harness = Harness.OnRamp(SteepGradient);
harness.Remote.Airborne = true;
harness.Tick(40);
Assert.True(harness.Remote.Body.HasGravity);
Assert.True(harness.Remote.Body.Acceleration.Z < -1f);
}
/// <summary>
/// The visible consequence: a remote resting on a non-walkable face keeps
/// moving. Before the fix the body reported <c>moved=0.0000</c> on every
/// tick, forever.
/// </summary>
[Fact]
public void SteepContactKeepsTheBodySlidingDownhill()
{
using Harness harness = Harness.OnRamp(SteepGradient);
Vector3 start = harness.Remote.Body.Position;
harness.Tick(40);
Vector3 travelled = harness.Remote.Body.Position - start;
Assert.True(
travelled.Length() > 0.25f,
$"expected a slide, body moved {travelled.Length():F4} m");
Assert.True(
travelled.Z < -0.1f,
$"expected downhill travel, dz = {travelled.Z:F4} m");
}
/// <summary>
/// The direct statement of "stop forging inputs": with no sweep to derive
/// from — no starting cell, so <c>ResolveWithTransition</c> is skipped
/// entirely — the tick must leave both retail transients exactly as it
/// found them. Retail's only writer is <c>SetPositionInternal</c>
/// (<c>0x00515330</c>), which a skipped transition never reaches.
/// </summary>
[Fact]
public void TheTickNeverAssertsContactOrWalkableWithoutASweep()
{
using Harness harness = Harness.OnRamp(WalkableGradient);
harness.Remote.CellId = 0u;
harness.Remote.Body.TransientState &= ~(TransientStateFlags.Contact
| TransientStateFlags.OnWalkable);
harness.Remote.Airborne = false;
harness.Tick(1);
Assert.False(harness.Remote.Body.InContact);
Assert.False(harness.Remote.Body.OnWalkable);
}
/// <summary>
/// The tick immediately after a body crossed from walkable ground onto a
/// steep face: it enters carrying last tick's grounded transients and its
/// downhill speed. The tick must NOT re-assert those transients — with
/// <c>Contact | OnWalkable</c> forced, <c>calc_acceleration</c>
/// (<c>0x00510950</c>) returns zero and <c>calc_friction</c>
/// (<c>0x0050EE70</c>) engages, so the body decelerates to a stop on a face
/// retail would keep accelerating it down.
/// </summary>
[Fact]
public void AGroundedTickOnASteepFaceReleasesTheBodyInsteadOfPinningIt()
{
using Harness harness = Harness.OnRamp(SteepGradient);
harness.Remote.Body.TransientState |=
TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
harness.Remote.Airborne = false;
harness.Remote.Body.Velocity =
Vector3.Normalize(new Vector3(0f, 1f, -SteepGradient)) * 3f;
Vector3 start = harness.Remote.Body.Position;
harness.Tick(40);
Assert.False(harness.Remote.Body.OnWalkable);
float travelled = (harness.Remote.Body.Position - start).Length();
Assert.True(
travelled > 2f,
$"expected the steep face to release the body, travelled {travelled:F3} m");
}
/// <summary>
/// Retail's <c>MoveOrTeleport</c> (<c>0x00516330</c>) never reads or writes
/// the wire velocity for a remote; the deleted per-tick
/// <c>Body.Velocity = Zero</c> threw away whatever ACE delivered through
/// <c>0xF74E</c> as well as everything gravity had accumulated.
/// </summary>
[Fact]
public void AuthoritativeVelocityIsNotDiscardedOnAGroundedTick()
{
using Harness harness = Harness.OnRamp(WalkableGradient);
Assert.False(harness.Remote.Airborne);
harness.Remote.Body.Velocity = new Vector3(2.146f, 2.264f, -3.549f);
harness.Tick(1);
Assert.NotEqual(Vector3.Zero, harness.Remote.Body.Velocity);
Assert.True(
harness.Remote.Body.Velocity.X > 0.5f,
$"velocity X was {harness.Remote.Body.Velocity.X:F4}");
}
/// <summary>
/// The committed transients must be the ones the sweep's contact plane
/// implies — Contact from plane validity, OnWalkable from
/// <c>Normal.Z &gt;= floor_z</c> — and never an independently asserted pair.
///
/// <para>
/// Deliberately NOT stated as the two equalities
/// <c>ContactPlaneValid == InContact</c> and
/// <c>IsWalkableContact(committed plane) == OnWalkable</c>. Neither is an
/// invariant of the production code, and this test asserted both until the
/// 2026-08-04 review: <c>PhysicsEngine.ResolveWithTransition</c> publishes
/// the contact plane whenever the transition returned <c>ok</c>, while the
/// transient commit additionally requires <c>candidateMoved</c>
/// (<c>RuntimeRemotePhysicsUpdater</c>'s SetPositionInternal commit,
/// matching retail <c>UpdateObjectInternal</c> pc:283657), so a zero-move
/// frame can legitimately leave the two one tick apart. The same writeback
/// also falls back to <c>LastKnownContactPlane</c>, which keeps
/// <c>ContactPlaneValid</c> true across a contact-FREE frame by design.
/// The old assertions passed only because every body in this fixture moves
/// on every tick. What is asserted instead is what the commit path DOES
/// guarantee — the two implications — plus each fixture's known ramp
/// geometry checked on BOTH sides of retail's walkability comparison, so
/// re-forging <c>Contact | OnWalkable</c> still fails the steep case.
/// </para>
/// </summary>
[Fact]
public void CommittedTransientsAgreeWithTheCommittedContactPlane()
{
using Harness steep = Harness.OnRamp(SteepGradient);
steep.Tick(20);
AssertTransientsAreContactPlaneDerived(
steep.Remote.Body, expectWalkable: false);
using Harness gentle = Harness.OnRamp(WalkableGradient);
gentle.Tick(20);
AssertTransientsAreContactPlaneDerived(
gentle.Remote.Body, expectWalkable: true);
}
private static void AssertTransientsAreContactPlaneDerived(
PhysicsBody body,
bool expectWalkable)
{
// Unconditional: OnWalkable is only ever written as
// `inContact && onWalkable`
// (PhysicsObjUpdate.CommitSetPositionContactPrefix), so it cannot
// outlive Contact on any frame, committed or not.
Assert.True(
!body.OnWalkable || body.InContact,
"OnWalkable without Contact — the two transients were asserted "
+ "independently of the contact plane");
// Contact is written from the sweep's contact-plane validity by the
// same resolve that publishes the plane, so a body in contact carries
// a valid plane. The CONVERSE is not guaranteed — see the summary.
Assert.True(
!body.InContact || body.ContactPlaneValid,
"Contact without a valid contact plane — Contact was not "
+ "plane-derived");
// Both sides of retail's walkability comparison
// (SetPositionInternal 0x00515465-0x0051548E) against this fixture's
// known constant-gradient ramp: the plane the sweep found, and the
// transient that plane drove.
Assert.Equal(
expectWalkable,
body.ContactPlane.Normal.Z >= PhysicsGlobals.FloorZ);
Assert.Equal(expectWalkable, body.OnWalkable);
}
/// <summary>
/// The other half of the edge: a genuine walkable landing must still fire
/// retail's <c>set_on_walkable(1)</c> -> <c>MovementManager::HitGround</c>
/// exactly once and leave the body grounded.
/// </summary>
[Fact]
public void WalkableLandingStillLandsAndFiresTheGroundEdgeOnce()
{
using Harness harness = Harness.Airborne(WalkableGradient, height: 3f);
int groundEdges = 0;
harness.Remote.Motion.RemoveLinkAnimations = () => groundEdges++;
harness.Tick(60);
Assert.True(harness.Remote.Body.OnWalkable);
Assert.False(harness.Remote.Airborne);
Assert.Equal(1, groundEdges);
}
/// <summary>
/// GRAVITY_PS is set by the retail <c>CPhysicsObj</c> constructor
/// (state 0x400C08 @0x00512508) and thereafter assigned wholesale from the
/// wire by <c>set_description</c>'s <c>set_state</c> (<c>0x00514DD0</c>),
/// which post-processes only lighting/nodraw/hidden. No ground edge
/// anywhere in retail toggles it — acdream's two landing blocks did, which
/// is what left a landed remote permanently unable to fall again.
/// </summary>
[Fact]
public void WalkableLandingDoesNotClearTheGravityStateBit()
{
using Harness harness = Harness.Airborne(WalkableGradient, height: 3f);
harness.Tick(60);
Assert.True(harness.Remote.Body.OnWalkable);
Assert.True(harness.Remote.Body.HasGravity);
}
private sealed class Harness : IDisposable
{
private const uint LandblockId = 0x0101FFFFu;
private readonly RuntimeEntityObjectLifetime _lifetime;
private readonly RuntimeEntityRecord _record;
private readonly RuntimeRemotePhysicsUpdater _updater;
internal RemoteMotion Remote { get; }
private Harness(
RuntimeEntityObjectLifetime lifetime,
RuntimeEntityRecord record,
RemoteMotion remote,
RuntimeRemotePhysicsUpdater updater)
{
_lifetime = lifetime;
_record = record;
Remote = remote;
_updater = updater;
}
/// <summary>Body already resting on the ramp, contact established.</summary>
internal static Harness OnRamp(float gradient)
{
Harness 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 Harness Airborne(float gradient, float height)
{
Harness 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 Harness Create(float gradient, float heightAboveSurface)
{
var lifetime = new RuntimeEntityObjectLifetime();
lifetime.Physics.Engine.AddLandblock(
LandblockId,
Ramp(gradient),
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);
const float localX = 96f;
const float localY = 96f;
float surfaceZ = Ramp(gradient).SampleZ(localX, localY);
body.Position = new Vector3(
localX,
localY,
surfaceZ + heightAboveSurface);
body.Orientation = Quaternion.Identity;
remote.CellId = TerrainSurface.ComputeOutdoorCellId(
LandblockId,
localX,
localY);
remote.LastServerPos = body.Position;
remote.LastServerPosTime = 1.0;
return new Harness(
lifetime,
record,
remote,
new RuntimeRemotePhysicsUpdater(lifetime.Physics));
}
internal void Tick(int count, float dt = 1f / 30f)
{
var frame = new MotionDeltaFrame();
for (int i = 0; i < count; i++)
{
frame.Reset();
_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 climbing along +Y. 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 exactly <c>normalize((0, -gradient, 1))</c>.
/// </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,
96f,
96f,
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,
"bug-b-fixture",
null,
null,
0x09000001u,
PhysicsState: rawState,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
public void Dispose() => _lifetime.Dispose();
}
}