Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Measured live at Rithwic 2026-08-06 with ACDREAM_DUMP_EDGE_SLIDE=1. Six branch2/steep-cliffslide events, every one reporting curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000 apply=False, outcome degenerate-cross/last-known. That is decision-table row 1 of the research doc, verbatim. CTransition::cliff_slide @0x0050a6d0 takes its slide direction from cross(steep contact normal, last_known_contact_plane.N) — it needs the surface the mover was STANDING ON as the second vector. acdream's CollisionInfo.SetContactPlane latched the last-known group on every call, so by the time cliff_slide ran, last-known had already been overwritten with the steep face itself: the cross product of a vector with itself, which is zero. Degenerate direction, no slide, walk off the cliff. Retail's COLLISIONINFO::set_contact_plane @0x00509d80 is 22 bytes and writes the CONTACT group only; the last-known group has four writers, none of them that function. So the four writes are DELETED and a new InitContactPlane mirrors CTransition::init_contact_plane @0x0050e850, writing both — the start-of-transition seed, where there is no earlier surface to remember. Only check_contact's SUCCESS branch calls it. The other eleven call sites keep the narrowed setter. This is a port, not a suppression: no guard, no grace period, no flag. The user's own A/B was the discriminator: Neftet's block plateaus hold (188 branch3/precipice-slide events, all before the teleport) while Rithwic's terrain cliff fails (6 branch2 events, all after). I had predicted the opposite — that terrain would be the flat-normal case — and position plus timeline corrected me, not reasoning. NEW DISCRIMINATING TEST, because the suite had none. It was green both before and after the production change, so nothing in it defended this behaviour. Issue32LastKnownContactPlaneTests seeds a walkable plane, asserts a steep mid-transition contact leaves it intact, and asserts the resulting cross product is non-degenerate. Sabotage-verified: restore the four writes and both discriminating rows fail while the InitContactPlane control keeps passing — the pair separates 'the latch is gone' from 'nothing writes last-known at all'. Two existing tests corrected rather than deleted. PhysicsSetPositionTests.FailedCheck_MapsCollisionHandlerResultToRetailError passed BECAUSE of the latch (the file the research named); its hook now populates both groups explicitly, since it asserts report plumbing, not setter semantics. RetailEdgeResponseOrderingTests.TransitionalInsert_ DegenerateCliffSlideOk_ContinuesOuterRetry was predicted to fail and did not — it now passes for a DIFFERENT reason (last-known absent rather than clobbered, which retail also answers with OK_TS). Its comment described the deleted behaviour and is corrected to say so, and to say it does not discriminate this fix. Also repairs the #338 probe. Its first placement in PlayerMovementController printed nothing across 11,523 live log lines — the wrong one of two resolve call sites — so it moves to PhysicsEngine.ResolveWithTransition where every caller passes through, filtered to the player. The dead site is removed rather than left in place; a probe that never fires is worse than none. The flag test now precedes the interpolated string: building it eagerly cost 128 B per resolve with the probe OFF, which Slice I1's zero-allocation gate caught. Suite 11,234 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
922 lines
37 KiB
C#
922 lines
37 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Text;
|
|
using AcDream.Core.Physics;
|
|
using DatReaderWriter.Types;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// Pins the branch order in retail <c>CTransition::transitional_insert</c>,
|
|
/// <c>CTransition::edge_slide</c>, and <c>CTransition::cliff_slide</c>.
|
|
/// These cases distinguish the retail implementation from the four former
|
|
/// acdream compensations tracked as AP-3, AP-4, AD-53, and AD-54.
|
|
/// </summary>
|
|
public sealed class RetailEdgeResponseOrderingTests
|
|
{
|
|
private const uint Cell = 0xA9B40001u;
|
|
|
|
[Fact]
|
|
public void TransitionalInsert_ValidSteepContact_ReturnsBeforeOrdinaryStepDownTail()
|
|
{
|
|
Vector3 current = new(2f, 3f, 4f);
|
|
Vector3 target = current + new Vector3(0.1f, 0f, 0f);
|
|
var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell);
|
|
transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
|
|
transition.ObjectInfo.StepDown = true;
|
|
transition.ObjectInfo.StepDownHeight = 2f;
|
|
|
|
Vector3 untouchedBackup = new(97f, 98f, 99f);
|
|
const uint untouchedBackupCell = 0xA9B40044u;
|
|
transition.SpherePath.BackupCheckPos = untouchedBackup;
|
|
transition.SpherePath.BackupCheckCellId = untouchedBackupCell;
|
|
|
|
var steep = new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
|
|
var engine = new PhysicsEngine
|
|
{
|
|
TransitionCellCollisionTestHook = (candidate, phase, _, actual) =>
|
|
{
|
|
if (phase == TransitionCellCollisionPhase.Objects)
|
|
candidate.CollisionInfo.SetContactPlane(steep, Cell, isWater: true);
|
|
return actual;
|
|
},
|
|
};
|
|
|
|
TransitionState result = transition.TransitionalInsertForTest(1, engine);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.True(transition.CollisionInfo.ContactPlaneValid);
|
|
Assert.True(transition.CollisionInfo.ContactPlaneIsWater);
|
|
Assert.Equal(steep, transition.CollisionInfo.ContactPlane);
|
|
Assert.Equal(untouchedBackup, transition.SpherePath.BackupCheckPos);
|
|
Assert.Equal(untouchedBackupCell, transition.SpherePath.BackupCheckCellId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1, 0.5f, 2.0f, 0.25f, 1)]
|
|
[InlineData(2, 0.5f, 2.0f, 1.00f, 2)]
|
|
[InlineData(1, 0.5f, 0.75f, 0.75f, 1)]
|
|
[InlineData(2, 0.5f, 0.75f, 0.75f, 1)]
|
|
public void StepDownProbePlan_PreservesRetailOneVersusTwoSphereSplit(
|
|
int sphereCount,
|
|
float radius,
|
|
float requestedHeight,
|
|
float expectedProbeHeight,
|
|
int expectedProbeCount)
|
|
{
|
|
(float probeHeight, int probeCount) = Transition.GetStepDownProbePlan(
|
|
sphereCount,
|
|
radius,
|
|
requestedHeight);
|
|
|
|
Assert.Equal(expectedProbeHeight, probeHeight);
|
|
Assert.Equal(expectedProbeCount, probeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EdgeSlide_NotOnWalkableSteepContact_RestoresBeforeCliffSlide()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.ObjectInfo.State = ObjectInfoState.EdgeSlide;
|
|
transition.CollisionInfo.ContactPlaneValid = true;
|
|
transition.CollisionInfo.ContactPlane =
|
|
new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
|
|
transition.CollisionInfo.ContactPlaneIsWater = true;
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
|
|
Vector3 failedCandidate = transition.SpherePath.BackupCheckPos;
|
|
bool stop = transition.EdgeSlideAfterStepDownFailedForTest(
|
|
new PhysicsEngine(),
|
|
stepDownHeight: 0.04f,
|
|
zVal: PhysicsGlobals.FloorZ,
|
|
out TransitionState result);
|
|
|
|
Assert.True(stop);
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.CollisionInfo.ContactPlaneValid);
|
|
Assert.False(transition.CollisionInfo.ContactPlaneIsWater);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void CliffSlide_UsesOnlyLastKnownContactPlaneNormal()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
|
|
// A qualifying remembered walkable normal deliberately points along Y.
|
|
// The former AD-53 fallback consumed it; retail consumes the explicit
|
|
// last-known contact normal below and therefore resolves along -X.
|
|
Plane rememberedWalkable = new(Vector3.Normalize(new Vector3(0f, 1f, 1f)), 0f);
|
|
transition.SpherePath.SetWalkable(
|
|
rememberedWalkable,
|
|
SquareOnPlaneZ0(),
|
|
Vector3.UnitZ);
|
|
transition.SpherePath.ClearWalkable();
|
|
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
|
|
|
|
TransitionState result = transition.CliffSlideForTest(steepContact);
|
|
|
|
Assert.Equal(TransitionState.Adjusted, result);
|
|
Assert.True(transition.CollisionInfo.CollisionNormalValid);
|
|
Assert.True(Vector3.Distance(-Vector3.UnitX, transition.CollisionInfo.CollisionNormal) < 0.0001f);
|
|
}
|
|
|
|
[Fact]
|
|
public void CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = false;
|
|
transition.CollisionInfo.LastKnownContactPlane = default;
|
|
Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
|
|
|
|
TransitionState result = transition.CliffSlideForTest(steepContact);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void EdgeSlide_StoredSteepWalkable_AlwaysRoutesToPrecipiceSlide()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.ObjectInfo.State =
|
|
ObjectInfoState.Contact | ObjectInfoState.OnWalkable | ObjectInfoState.EdgeSlide;
|
|
transition.CollisionInfo.ContactPlaneValid = false;
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
|
|
Vector3 steepNormal = Vector3.Normalize(new Vector3(-2f, 0f, 1f));
|
|
var steepPlane = new Plane(steepNormal, 0f);
|
|
Vector3[] steepQuad =
|
|
[
|
|
new(0f, -1f, 0f),
|
|
new(1f, -1f, 2f),
|
|
new(1f, 1f, 2f),
|
|
new(0f, 1f, 0f),
|
|
];
|
|
transition.SpherePath.SetWalkable(steepPlane, steepQuad, Vector3.UnitZ);
|
|
|
|
Vector3 failedCandidate = new(0.5f, 0f, 1f);
|
|
transition.SpherePath.SetCheckPos(failedCandidate, Cell);
|
|
transition.SpherePath.SaveCheckPos();
|
|
transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
|
|
|
|
bool stop = transition.EdgeSlideAfterStepDownFailedForTest(
|
|
new PhysicsEngine(),
|
|
stepDownHeight: 0.04f,
|
|
zVal: PhysicsGlobals.FloorZ,
|
|
out TransitionState result);
|
|
|
|
// The restored point is inside the remembered polygon, so retail's
|
|
// unconditional PrecipiceSlide returns Collided. AD-54's steep-plane
|
|
// reroute instead returned Adjusted through CliffSlide.
|
|
Assert.True(stop);
|
|
Assert.Equal(TransitionState.Collided, result);
|
|
Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.SpherePath.HasWalkablePolygon);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void TransitionalInsert_DegenerateCliffSlideOk_ContinuesOuterRetry()
|
|
{
|
|
Vector3 current = new(2f, 3f, 4f);
|
|
Vector3 target = current + new Vector3(0.1f, 0f, 0f);
|
|
var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell);
|
|
transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
|
|
transition.ObjectInfo.StepDown = true;
|
|
transition.ObjectInfo.StepDownHeight = 0.04f;
|
|
|
|
Plane steep = new(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
|
|
int outerObjectPasses = 0;
|
|
var engine = new PhysicsEngine
|
|
{
|
|
TransitionCellCollisionTestHook = (candidate, phase, _, actual) =>
|
|
{
|
|
if (phase != TransitionCellCollisionPhase.Objects)
|
|
return actual;
|
|
|
|
if (candidate.SpherePath.StepDown)
|
|
{
|
|
// The nested downward probe finds a steep contact. It is
|
|
// rejected as walkable, and CliffSlide takes its degenerate
|
|
// OK_TS return with stop=false.
|
|
//
|
|
// #32 (2026-08-07) — WHY THIS STILL PASSES, because the
|
|
// reason CHANGED and the old comment here was describing
|
|
// deleted behaviour. It used to read "because
|
|
// SetContactPlane also latches the same last-known plane,
|
|
// CliffSlide's cross is parallel/degenerate". That latch is
|
|
// gone. This fixture never seeds a last-known plane, so
|
|
// last-known is now simply ABSENT, and CliffSlide's
|
|
// degenerate return covers that case too — retail genuinely
|
|
// returns OK_TS when last-known is missing (see
|
|
// CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn).
|
|
// Same outcome, different cause. This test therefore does
|
|
// NOT discriminate the #32 fix; Issue32LastKnownContactPlaneTests
|
|
// does, and is sabotage-verified.
|
|
candidate.CollisionInfo.SetContactPlane(steep, Cell);
|
|
}
|
|
else
|
|
{
|
|
outerObjectPasses++;
|
|
if (outerObjectPasses == 2)
|
|
candidate.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), Cell);
|
|
}
|
|
|
|
return actual;
|
|
},
|
|
};
|
|
|
|
TransitionState result = transition.TransitionalInsertForTest(2, engine);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.Equal(2, outerObjectPasses);
|
|
Assert.True(transition.CollisionInfo.ContactPlaneValid);
|
|
Assert.Equal(Vector3.UnitZ, transition.CollisionInfo.ContactPlane.Normal);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiFrameSteepRoof_PureVertical_GraphAndFlatSlideDownhillWithoutWedge()
|
|
{
|
|
TraceRun graph = RunSteepRoofTrace(preparedFlat: false, Vector2.Zero);
|
|
TraceRun flat = RunSteepRoofTrace(preparedFlat: true, Vector2.Zero);
|
|
|
|
AssertTraceParity(graph, flat);
|
|
Assert.Contains(graph.Frames, frame => frame.Result.InContact);
|
|
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
|
|
Assert.True(graph.Frames[^1].Result.Position.X
|
|
< graph.Frames[0].Result.Position.X - 0.20f,
|
|
$"The vertical trace did not descend the roof: " +
|
|
$"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}.");
|
|
|
|
Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
|
|
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
|
|
float radius = BSPStepUpFixtures.SphereRadius;
|
|
for (int i = 0; i < graph.Frames.Count; i++)
|
|
{
|
|
Vector3 position = graph.Frames[i].Result.Position;
|
|
AssertFinite(position, $"steep-roof frame {i}");
|
|
if (i > 0)
|
|
{
|
|
float distance = Vector3.Distance(
|
|
graph.Frames[i - 1].Result.Position,
|
|
position);
|
|
Assert.InRange(distance, 0f, 1.1f);
|
|
}
|
|
|
|
// While the foot center remains over the authored slope's XY
|
|
// footprint, it must stay on/outside the plane by at least its
|
|
// radius. Once X leaves [0,1], the body has exited the polygon and
|
|
// may fall toward the separate flat reference floor.
|
|
if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f)
|
|
{
|
|
Vector3 footCenter = position + new Vector3(0f, 0f, radius);
|
|
float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D;
|
|
Assert.True(signedDistance >= radius - 0.015f,
|
|
$"Steep-roof penetration at frame {i}: distance={signedDistance}, " +
|
|
$"radius={radius}, position={position}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(-0.30f, 0f, "downhill")]
|
|
[InlineData( 0.30f, 0f, "uphill")]
|
|
[InlineData( 0f, 0.30f, "tangential")]
|
|
public void MultiFrameSteepRoof_DirectionalMotion_RemainsExactAndPreservesRetailResponse(
|
|
float velocityX,
|
|
float velocityY,
|
|
string direction)
|
|
{
|
|
Vector2 horizontalVelocity = new(velocityX, velocityY);
|
|
TraceRun graph = RunSteepRoofTrace(preparedFlat: false, horizontalVelocity);
|
|
TraceRun flat = RunSteepRoofTrace(preparedFlat: true, horizontalVelocity);
|
|
|
|
AssertTraceParity(graph, flat);
|
|
Assert.Contains(graph.Frames, frame => frame.Result.InContact);
|
|
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
|
|
|
|
Vector3 first = graph.Frames[0].Result.Position;
|
|
Vector3 last = graph.Frames[^1].Result.Position;
|
|
Vector2 progress = new(last.X - first.X, last.Y - first.Y);
|
|
if (direction == "uphill")
|
|
{
|
|
int contactFrame = graph.Frames.FindIndex(frame => frame.Result.InContact);
|
|
Assert.True(contactFrame >= 0);
|
|
float peakAfterContact = graph.Frames
|
|
.GetRange(contactFrame, graph.Frames.Count - contactFrame)
|
|
.Max(frame => frame.Result.Position.Z);
|
|
Assert.True(peakAfterContact <= graph.Frames[contactFrame].Result.Position.Z + 0.001f,
|
|
$"The uphill trace launched/bounced from the roof: " +
|
|
$"contactZ={graph.Frames[contactFrame].Result.Position.Z}, peak={peakAfterContact}.");
|
|
}
|
|
else
|
|
{
|
|
Assert.True(Vector2.Dot(progress, Vector2.Normalize(horizontalVelocity)) > 0.10f,
|
|
$"The {direction} trace lost requested progress: {first} -> {last}.");
|
|
}
|
|
|
|
Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
|
|
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
|
|
float radius = BSPStepUpFixtures.SphereRadius;
|
|
for (int i = 0; i < graph.Frames.Count; i++)
|
|
{
|
|
Vector3 position = graph.Frames[i].Result.Position;
|
|
AssertFinite(position, $"steep-roof {direction} frame {i}");
|
|
if (i > 0)
|
|
{
|
|
float distance = Vector3.Distance(
|
|
graph.Frames[i - 1].Result.Position,
|
|
position);
|
|
Assert.InRange(distance, 0f, 1.1f);
|
|
}
|
|
|
|
if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f)
|
|
{
|
|
Vector3 footCenter = position + new Vector3(0f, 0f, radius);
|
|
float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D;
|
|
Assert.True(signedDistance >= radius - 0.015f,
|
|
$"Steep-roof {direction} penetration at frame {i}: " +
|
|
$"distance={signedDistance}, radius={radius}, position={position}.");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge()
|
|
{
|
|
TraceRun graph = RunFlatRoofLedgeTrace(preparedFlat: false, includeRoof: true);
|
|
TraceRun flat = RunFlatRoofLedgeTrace(preparedFlat: true, includeRoof: true);
|
|
TraceRun noRoof = RunFlatRoofLedgeTrace(preparedFlat: false, includeRoof: false);
|
|
|
|
AssertTraceParity(graph, flat);
|
|
Assert.True(graph.LandedFrame > 0, "The collision-backed control never landed on the roof.");
|
|
Assert.Equal(-1, noRoof.LandedFrame);
|
|
Assert.All(graph.Frames.GetRange(0, graph.LandedFrame), frame =>
|
|
Assert.False(frame.Result.OnWalkable));
|
|
TraceFrame landing = graph.Frames[graph.LandedFrame];
|
|
Assert.True(landing.Result.Ok);
|
|
Assert.True(landing.Result.IsOnGround);
|
|
Assert.True(landing.Result.InContact);
|
|
Assert.True(landing.Result.OnWalkable);
|
|
Assert.True(landing.BodyContactPlaneValid);
|
|
Assert.True(landing.BodyWalkablePolygonValid);
|
|
Assert.Equal(Vector3.UnitZ, landing.BodyContactPlane.Normal);
|
|
Assert.Equal(
|
|
TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
|
landing.BodyTransientState
|
|
& (TransientStateFlags.Contact | TransientStateFlags.OnWalkable));
|
|
|
|
List<TraceFrame> ledge = graph.Frames.GetRange(
|
|
graph.LedgeStartFrame,
|
|
graph.Frames.Count - graph.LedgeStartFrame);
|
|
AssertNoLongFrozenStreak(ledge, maximumTicks: 15);
|
|
int outwardRejectionFrame = ledge.FindIndex(frame =>
|
|
frame.Result.CollisionNormalValid
|
|
&& frame.Result.CollisionNormal.X < -0.9f);
|
|
Assert.True(outwardRejectionFrame >= 0,
|
|
"The roof-edge control never produced the expected outward-X rejection normal.");
|
|
float rejectedX = ledge[outwardRejectionFrame].Result.Position.X;
|
|
Assert.All(ledge.GetRange(
|
|
outwardRejectionFrame,
|
|
ledge.Count - outwardRejectionFrame),
|
|
frame => Assert.True(frame.Result.Position.X <= rejectedX + 0.001f,
|
|
$"Outward X resumed after rejection: {frame.Result.Position.X} > {rejectedX}."));
|
|
float unobstructedFinalX = ledge[0].Result.Position.X + 0.12f * (ledge.Count - 1);
|
|
Assert.True(ledge[^1].Result.Position.X < unobstructedFinalX - 0.25f,
|
|
$"Roof edge failed to remove outward travel: final={ledge[^1].Result.Position.X}, " +
|
|
$"unobstructed={unobstructedFinalX}.");
|
|
Assert.True(ledge[^1].Result.Position.Y
|
|
> ledge[outwardRejectionFrame].Result.Position.Y + 0.25f,
|
|
$"The roof edge rejected all tangency: {ledge[outwardRejectionFrame].Result.Position} -> " +
|
|
$"{ledge[^1].Result.Position}.");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void MultiFrameGroundedFloorWallSlide_InwardTangentialMotionIsGraphFlatExact(
|
|
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;
|
|
Vector3 failedCandidate = new(1f, 0f, 0f);
|
|
var transition = BSPStepUpFixtures.MakeGroundedTransition(
|
|
current,
|
|
failedCandidate,
|
|
cellId: Cell);
|
|
transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
|
|
transition.SpherePath.SetCheckPos(failedCandidate, Cell);
|
|
transition.SpherePath.SaveCheckPos();
|
|
transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
|
|
return transition;
|
|
}
|
|
|
|
private static Vector3[] SquareOnPlaneZ0() =>
|
|
[
|
|
new(-2f, -2f, 0f),
|
|
new( 2f, -2f, 0f),
|
|
new( 2f, 2f, 0f),
|
|
new(-2f, 2f, 0f),
|
|
];
|
|
|
|
private static TraceRun RunSteepRoofTrace(
|
|
bool preparedFlat,
|
|
Vector2 horizontalVelocity)
|
|
{
|
|
var fixture = BSPStepUpFixtures.SlopedUnwalkable();
|
|
PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u);
|
|
float radius = BSPStepUpFixtures.SphereRadius;
|
|
const float dt = 1f / 30f;
|
|
const float gravity = -9.8f;
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = new Vector3(0.5f, 0f, 3f),
|
|
Orientation = Quaternion.Identity,
|
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
Vector3 position = new(0.5f, 0f, 3f);
|
|
float velocityZ = 0f;
|
|
var trace = new List<TraceFrame>(90);
|
|
|
|
for (int tick = 0; tick < 90; tick++)
|
|
{
|
|
velocityZ += gravity * dt;
|
|
body.Velocity = new Vector3(
|
|
horizontalVelocity.X,
|
|
horizontalVelocity.Y,
|
|
velocityZ);
|
|
ResolveResult result = engine.ResolveWithTransition(
|
|
position,
|
|
position + body.Velocity * dt,
|
|
Cell,
|
|
radius,
|
|
radius * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: body.OnWalkable,
|
|
body,
|
|
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000000u);
|
|
|
|
position = result.Position;
|
|
body.Position = position;
|
|
if (result.IsOnGround)
|
|
{
|
|
velocityZ = 0f;
|
|
body.Velocity = new Vector3(
|
|
horizontalVelocity.X,
|
|
horizontalVelocity.Y,
|
|
0f);
|
|
}
|
|
ApplyContactResult(body, result);
|
|
trace.Add(CaptureFrame(result, body));
|
|
|
|
if (position.X < 0f && position.Z <= radius + 0.05f)
|
|
break;
|
|
}
|
|
|
|
return new TraceRun(trace, LandedFrame: -1, LedgeStartFrame: -1);
|
|
}
|
|
|
|
private static TraceRun RunFlatRoofLedgeTrace(bool preparedFlat, bool includeRoof)
|
|
{
|
|
var fixture = BSPStepUpFixtures.FlatRoof();
|
|
PhysicsEngine engine = BuildCollisionEngine(
|
|
fixture,
|
|
preparedFlat,
|
|
0x0100E102u,
|
|
includeGeometry: includeRoof);
|
|
float radius = BSPStepUpFixtures.SphereRadius;
|
|
const float dt = 1f / 30f;
|
|
Vector3 position = new(1.55f, -0.75f, 3.6f);
|
|
float velocityZ = 0f;
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = position,
|
|
Orientation = Quaternion.Identity,
|
|
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
var trace = new List<TraceFrame>(72);
|
|
int landedFrame = -1;
|
|
|
|
for (int tick = 0; tick < 60; tick++)
|
|
{
|
|
velocityZ += PhysicsBody.Gravity * dt;
|
|
body.Velocity = new Vector3(0f, 0f, velocityZ);
|
|
ResolveResult result = engine.ResolveWithTransition(
|
|
position,
|
|
position + new Vector3(0f, 0f, velocityZ * dt),
|
|
Cell,
|
|
radius,
|
|
radius * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: body.OnWalkable,
|
|
body,
|
|
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000001u);
|
|
|
|
position = result.Position;
|
|
body.Position = position;
|
|
ApplyContactResult(body, result);
|
|
trace.Add(CaptureFrame(result, body));
|
|
|
|
if (result.InContact && result.OnWalkable)
|
|
{
|
|
landedFrame = trace.Count - 1;
|
|
velocityZ = 0f;
|
|
body.Velocity = Vector3.Zero;
|
|
break;
|
|
}
|
|
}
|
|
|
|
int ledgeStartFrame = trace.Count;
|
|
if (landedFrame >= 0)
|
|
{
|
|
for (int tick = 0; tick < 12; tick++)
|
|
{
|
|
ResolveResult result = engine.ResolveWithTransition(
|
|
position,
|
|
position + new Vector3(0.12f, 0.08f, 0f),
|
|
Cell,
|
|
radius,
|
|
radius * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: body.OnWalkable,
|
|
body,
|
|
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000001u);
|
|
|
|
position = result.Position;
|
|
body.Position = position;
|
|
ApplyContactResult(body, result);
|
|
trace.Add(CaptureFrame(result, body));
|
|
}
|
|
}
|
|
|
|
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,
|
|
uint gfxObjId,
|
|
bool includeGeometry = true)
|
|
{
|
|
var normalized = new Dictionary<ushort, ResolvedPolygon>(fixture.Resolved.Count);
|
|
foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved)
|
|
{
|
|
normalized.Add(id, new ResolvedPolygon
|
|
{
|
|
Id = id,
|
|
Vertices = polygon.Vertices,
|
|
Plane = polygon.Plane,
|
|
NumPoints = polygon.NumPoints,
|
|
SidesType = polygon.SidesType,
|
|
});
|
|
}
|
|
|
|
var physics = new GfxObjPhysics
|
|
{
|
|
SourceId = gfxObjId,
|
|
BSP = new PhysicsBSPTree { Root = fixture.Root },
|
|
Resolved = normalized,
|
|
BoundingSphere = fixture.Root.BoundingSphere,
|
|
};
|
|
var cache = new PhysicsDataCache();
|
|
if (includeGeometry && preparedFlat)
|
|
{
|
|
cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
|
|
cache.CacheGfxObj(gfxObjId, FlatCollisionAssetBuilder.FlattenGfxObj(physics));
|
|
}
|
|
else if (includeGeometry)
|
|
{
|
|
cache.RegisterGfxObjForTest(gfxObjId, physics);
|
|
}
|
|
|
|
var heights = new byte[81];
|
|
var heightTable = new float[256];
|
|
Array.Fill(heightTable, -1000f);
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
engine.AddLandblock(
|
|
0xA9B40000u,
|
|
new TerrainSurface(heights, heightTable),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
0f,
|
|
0f);
|
|
if (includeGeometry)
|
|
{
|
|
engine.ShadowObjects.Register(
|
|
gfxObjId,
|
|
gfxObjId,
|
|
Vector3.Zero,
|
|
Quaternion.Identity,
|
|
fixture.Root.BoundingSphere.Radius,
|
|
0f,
|
|
0f,
|
|
0xA9B4FFFFu,
|
|
ShadowCollisionType.BSP,
|
|
1f);
|
|
}
|
|
return engine;
|
|
}
|
|
|
|
private static void ApplyContactResult(PhysicsBody body, ResolveResult result)
|
|
{
|
|
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
|
if (result.InContact)
|
|
body.TransientState |= TransientStateFlags.Contact;
|
|
if (result.OnWalkable)
|
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
|
}
|
|
|
|
private static void AssertTraceParity(TraceRun expected, TraceRun actual)
|
|
{
|
|
Assert.Equal(expected.LandedFrame, actual.LandedFrame);
|
|
Assert.Equal(expected.LedgeStartFrame, actual.LedgeStartFrame);
|
|
Assert.Equal(expected.Frames.Count, actual.Frames.Count);
|
|
for (int i = 0; i < expected.Frames.Count; i++)
|
|
{
|
|
Assert.Equal(expected.Frames[i].ResolveBits, actual.Frames[i].ResolveBits);
|
|
Assert.Equal(expected.Frames[i].BodyBits, actual.Frames[i].BodyBits);
|
|
}
|
|
}
|
|
|
|
private static void AssertNoLongFrozenStreak(
|
|
IReadOnlyList<TraceFrame> trace,
|
|
int maximumTicks)
|
|
{
|
|
int streak = 0;
|
|
for (int i = 1; i < trace.Count; i++)
|
|
{
|
|
streak = Vector3.Distance(
|
|
trace[i - 1].Result.Position,
|
|
trace[i].Result.Position) < 0.001f
|
|
? streak + 1
|
|
: 0;
|
|
Assert.True(streak <= maximumTicks,
|
|
$"Trace froze for {streak} ticks at {trace[i].Result.Position}.");
|
|
}
|
|
}
|
|
|
|
private static TraceFrame CaptureFrame(ResolveResult result, PhysicsBody body) =>
|
|
new(
|
|
result,
|
|
ResolveSignature(result),
|
|
BodySignature(body),
|
|
body.ContactPlaneValid,
|
|
body.ContactPlane,
|
|
body.WalkablePolygonValid,
|
|
body.TransientState);
|
|
|
|
private static string ResolveSignature(ResolveResult result)
|
|
{
|
|
var signature = new StringBuilder(256);
|
|
Append(signature, result.Position);
|
|
Append(signature, result.CellId);
|
|
Append(signature, result.IsOnGround);
|
|
Append(signature, result.CollisionNormalValid);
|
|
Append(signature, result.CollisionNormal);
|
|
Append(signature, result.Ok);
|
|
Append(signature, result.Orientation);
|
|
Append(signature, result.InContact);
|
|
Append(signature, result.OnWalkable);
|
|
Append(signature, result.ContactPlane);
|
|
Append(signature, result.ContactPlaneCellId);
|
|
Append(signature, result.ContactPlaneIsWater);
|
|
return signature.ToString();
|
|
}
|
|
|
|
private static string BodySignature(PhysicsBody body)
|
|
{
|
|
var signature = new StringBuilder(512);
|
|
Append(signature, body.Position);
|
|
Append(signature, body.CellPosition.ObjCellId);
|
|
Append(signature, body.CellPosition.Frame.Origin);
|
|
Append(signature, body.CellPosition.Frame.Orientation);
|
|
Append(signature, body.InWorld);
|
|
Append(signature, body.Orientation);
|
|
Append(signature, body.Velocity);
|
|
Append(signature, body.CachedVelocity);
|
|
Append(signature, body.FramesStationaryFall);
|
|
Append(signature, body.Acceleration);
|
|
Append(signature, body.Omega);
|
|
Append(signature, body.GroundNormal);
|
|
Append(signature, body.SlidingNormal);
|
|
Append(signature, body.ContactPlaneValid);
|
|
Append(signature, body.ContactPlane);
|
|
Append(signature, body.ContactPlaneCellId);
|
|
Append(signature, body.ContactPlaneIsWater);
|
|
Append(signature, body.WalkablePolygonValid);
|
|
Append(signature, body.WalkablePlane);
|
|
if (body.WalkableVertices is null)
|
|
{
|
|
Append(signature, -1);
|
|
}
|
|
else
|
|
{
|
|
Append(signature, body.WalkableVertices.Length);
|
|
foreach (Vector3 vertex in body.WalkableVertices)
|
|
Append(signature, vertex);
|
|
}
|
|
Append(signature, body.WalkableUp);
|
|
Append(signature, body.Elasticity);
|
|
Append(signature, body.Friction);
|
|
Append(signature, (uint)body.State);
|
|
Append(signature, (uint)body.TransientState);
|
|
Append(signature, BitConverter.DoubleToUInt64Bits(body.LastUpdateTime));
|
|
Append(signature, body.IsFullyConstrained);
|
|
Append(signature, body.LastMoveWasAutonomous);
|
|
return signature.ToString();
|
|
}
|
|
|
|
private static void Append(StringBuilder target, bool value) =>
|
|
target.Append(value ? "1|" : "0|");
|
|
|
|
private static void Append(StringBuilder target, int value) =>
|
|
target.Append(value).Append('|');
|
|
|
|
private static void Append(StringBuilder target, uint value) =>
|
|
target.Append(value.ToString("X8")).Append('|');
|
|
|
|
private static void Append(StringBuilder target, ulong value) =>
|
|
target.Append(value.ToString("X16")).Append('|');
|
|
|
|
private static void Append(StringBuilder target, float value) =>
|
|
Append(target, BitConverter.SingleToUInt32Bits(value));
|
|
|
|
private static void Append(StringBuilder target, Vector3 value)
|
|
{
|
|
Append(target, value.X);
|
|
Append(target, value.Y);
|
|
Append(target, value.Z);
|
|
}
|
|
|
|
private static void Append(StringBuilder target, Quaternion value)
|
|
{
|
|
Append(target, value.X);
|
|
Append(target, value.Y);
|
|
Append(target, value.Z);
|
|
Append(target, value.W);
|
|
}
|
|
|
|
private static void Append(StringBuilder target, Plane value)
|
|
{
|
|
Append(target, value.Normal);
|
|
Append(target, value.D);
|
|
}
|
|
|
|
private static void AssertFinite(Vector3 value, string context)
|
|
{
|
|
Assert.True(
|
|
float.IsFinite(value.X) && float.IsFinite(value.Y) && float.IsFinite(value.Z),
|
|
$"Non-finite position in {context}: {value}.");
|
|
}
|
|
|
|
private sealed record TraceRun(
|
|
List<TraceFrame> Frames,
|
|
int LandedFrame,
|
|
int LedgeStartFrame);
|
|
|
|
private sealed record WallMaintenanceTrace(
|
|
List<TraceFrame> Frames,
|
|
int SupportPasses,
|
|
int PlacementPasses);
|
|
|
|
private sealed record TraceFrame(
|
|
ResolveResult Result,
|
|
string ResolveBits,
|
|
string BodyBits,
|
|
bool BodyContactPlaneValid,
|
|
Plane BodyContactPlane,
|
|
bool BodyWalkablePolygonValid,
|
|
TransientStateFlags BodyTransientState);
|
|
}
|