fix(physics): preserve retail step-down probe state

This commit is contained in:
Erik 2026-07-31 13:24:25 +02:00
parent 1fd5da67b4
commit acec33eca8
5 changed files with 249 additions and 36 deletions

View file

@ -322,6 +322,52 @@ public sealed class RetailEdgeResponseOrderingTests
$"{ledge[^1].Result.Position}.");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MultiFrameGroundedFloorWallSlide_FinalPlacementIsMandatoryAndGraphFlatExact(
bool twoSpheres)
{
WallMaintenanceTrace graph = RunGroundedFloorWallSlide(
preparedFlat: false,
twoSpheres);
WallMaintenanceTrace flat = RunGroundedFloorWallSlide(
preparedFlat: true,
twoSpheres);
Assert.Equal(graph.SupportPasses, flat.SupportPasses);
Assert.Equal(graph.PlacementPasses, flat.PlacementPasses);
Assert.True(graph.SupportPasses > 0,
"The full-engine replay never entered grounded support maintenance.");
Assert.Equal(graph.Frames.Count, graph.SupportPasses);
Assert.Equal(graph.SupportPasses, graph.PlacementPasses);
Assert.Equal(graph.Frames.Count, flat.Frames.Count);
for (int i = 0; i < graph.Frames.Count; i++)
{
Assert.Equal(graph.Frames[i].ResolveBits, flat.Frames[i].ResolveBits);
Assert.Equal(graph.Frames[i].BodyBits, flat.Frames[i].BodyBits);
Assert.True(graph.Frames[i].Result.Ok,
$"Grounded wall slide failed at frame {i}.");
Assert.True(graph.Frames[i].Result.InContact,
$"Ground contact was lost at frame {i}.");
Assert.True(graph.Frames[i].Result.OnWalkable,
$"Walkable state was lost at frame {i}.");
float wallLimit = 0.5f - BSPStepUpFixtures.SphereRadius
+ PhysicsGlobals.EPSILON * 10f;
Assert.True(graph.Frames[i].Result.Position.X <= wallLimit,
$"Wall penetration at frame {i}: X={graph.Frames[i].Result.Position.X}, " +
$"limit={wallLimit}.");
}
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 1);
Assert.True(
graph.Frames[^1].Result.Position.Y
> graph.Frames[0].Result.Position.Y + 0.25f,
$"Wall response removed tangential progress: " +
$"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}.");
}
private static Transition MakeFailedStepDownTransition()
{
Vector3 current = Vector3.Zero;
@ -468,6 +514,80 @@ public sealed class RetailEdgeResponseOrderingTests
return new TraceRun(trace, landedFrame, ledgeStartFrame);
}
private static WallMaintenanceTrace RunGroundedFloorWallSlide(
bool preparedFlat,
bool twoSpheres)
{
var fixture = BSPStepUpFixtures.TallWall();
PhysicsEngine engine = BuildCollisionEngine(
fixture,
preparedFlat,
0x0100E103u);
int supportPasses = 0;
int placementPasses = 0;
engine.TransitionCellCollisionTestHook = (candidate, phase, _, actual) =>
{
if (phase == TransitionCellCollisionPhase.Environment)
{
if (candidate.SpherePath.StepDown
&& actual == TransitionState.OK
&& candidate.CollisionInfo.ContactPlaneValid
&& candidate.CollisionInfo.ContactPlane.Normal.Z
>= candidate.SpherePath.WalkableAllowance)
supportPasses++;
else if (candidate.SpherePath.InsertType == InsertType.Placement)
placementPasses++;
}
return actual;
};
float radius = BSPStepUpFixtures.SphereRadius;
Vector3 position = new(0.5f - radius, -0.55f, 0f);
var floor = fixture.Resolved[BSPStepUpFixtures.TallWall_FloorId];
var body = new PhysicsBody
{
Position = position,
Orientation = Quaternion.Identity,
GroundNormal = Vector3.UnitZ,
ContactPlaneValid = true,
ContactPlane = floor.Plane,
ContactPlaneCellId = Cell,
WalkablePolygonValid = true,
WalkablePlane = floor.Plane,
WalkableVertices = floor.Vertices,
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Active
| TransientStateFlags.Contact
| TransientStateFlags.OnWalkable,
};
var trace = new List<TraceFrame>(10);
for (int tick = 0; tick < 10; tick++)
{
body.Velocity = new Vector3(1.8f, 2.1f, 0f);
ResolveResult result = engine.ResolveWithTransition(
position,
position + new Vector3(0.06f, 0.07f, 0f),
Cell,
radius,
sphereHeight: twoSpheres ? 1.835f : 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: true,
body,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000002u);
position = result.Position;
body.Position = position;
ApplyContactResult(body, result);
trace.Add(CaptureFrame(result, body));
}
return new WallMaintenanceTrace(trace, supportPasses, placementPasses);
}
private static PhysicsEngine BuildCollisionEngine(
(PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved) fixture,
bool preparedFlat,
@ -690,6 +810,11 @@ public sealed class RetailEdgeResponseOrderingTests
int LandedFrame,
int LedgeStartFrame);
private sealed record WallMaintenanceTrace(
List<TraceFrame> Frames,
int SupportPasses,
int PlacementPasses);
private sealed record TraceFrame(
ResolveResult Result,
string ResolveBits,