AD-10 claims the remote slope projection is "relocated" out of the sweep
because "remote bodies don't run a full local transition sweep". That
justification is false at HEAD: RuntimeRemotePhysicsUpdater.Tick calls
PhysicsEngine.ResolveWithTransition with the remote's own body, and that
sweep runs acdream's verbatim port of CTransition::adjust_offset
(0x0050a370, pc:272271-272393) once per sub-step. So the boundary
projection is an EXTRA layer, not a relocation — and whether it is doing
anything the sweep does not is a measurement, never an argument.
This commit builds the fixture for that measurement and changes no
production code.
RuntimeRemoteSteepContactSlideTests' private Harness is extracted to
RemoteRampHarness so the new tests share it instead of cloning ~180 lines.
The extraction is behaviour-preserving; its only additions are the
fixture's own TerrainSurface (so an assertion about "is the body on the
surface" is answered by the surface geometry rather than by
re-implementing what the code under test computed), a SurfaceZ helper, and
a Tick overload that supplies a per-frame body-local root displacement —
the locomotion-cycle push a running remote actually carries. All ten Bug B
tests pass unchanged against it.
RuntimeRemoteSlopeProjectionTests then drives the production tick 30 ticks
down a 31-degree walkable ramp and asserts, on EVERY tick rather than at
the end, that the body's root stays within 5 mm of its settled offset from
the terrain beneath it. A staircase catching up on the final tick would
pass a start/end comparison; 30 unprojected ticks accumulate ~1.8 m.
Sabotage results, all from clean builds (bin/obj deleted), reported in
both directions:
* Discard the sweep's answer (Body.Position = postIntegratePos instead
of resolveResult.Position): RED at tick 1, body 0.05999 m off the
surface. This is the tracking test's discriminating sabotage.
* Flatten the ramp to gradient 0: RED on the anti-vacuity guard
(dz = 0.0000 m). That guard exists because the tracking assertion
passes trivially on flat ground, where Z never has to move.
* Short-circuit Transition.AdjustOffset to `return offset;`: GREEN.
Recorded, not hidden — it is the reason the contract's proposed T1
sabotage was rejected. On terrain the sweep has a SECOND independent
way to plant Z: ValidateWalkable's push-out re-seats the sphere at its
natural resting distance from the terrain plane every sub-step.
Removing the step-down probe as well does not change it either
(measured). The tests therefore assert the OUTCOME the projection
exists for, and say in their own doc comments that they are not unit
tests of adjust_offset and must not be cited as such.
One test the contract asked for is deliberately absent. An uphill
counterpart was written, passed, and was then found VACUOUS: on this
fixture ResolveWithTransition returns ok=False for uphill motion and the
body does not move at all, so it "tracked the surface" by standing still.
That finding is filed separately rather than shipped as a green test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
13 KiB
C#
303 lines
13 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 < 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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 >= 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 RemoteRampHarness steep = RemoteRampHarness.OnRamp(SteepGradient);
|
|
steep.Tick(20);
|
|
AssertTransientsAreContactPlaneDerived(
|
|
steep.Remote.Body, expectWalkable: false);
|
|
|
|
using RemoteRampHarness gentle = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.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 RemoteRampHarness harness = RemoteRampHarness.Airborne(WalkableGradient, height: 3f);
|
|
|
|
harness.Tick(60);
|
|
|
|
Assert.True(harness.Remote.Body.OnWalkable);
|
|
Assert.True(harness.Remote.Body.HasGravity);
|
|
}
|
|
}
|