diff --git a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
index de6bf12b..89495a1b 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue185OutdoorStairsSeamReplayTests.cs
@@ -193,11 +193,7 @@ public class Issue185OutdoorStairsSeamReplayTests
public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill()
{
var engine = BuildStairEngine();
- var body = GroundedOnTread();
- body.Position = new Vector3(133.03775f, 75.53931f, 59.608147f);
- body.ContactPlane = new Plane(
- new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f),
- 0.75193405f);
+ var body = CapturedSideWallBody(x: 133.03775f);
ResolveResult result = engine.ResolveWithTransition(
currentPos: body.Position,
@@ -225,6 +221,64 @@ public class Issue185OutdoorStairsSeamReplayTests
$"{body.Position.Z:F6} -> {result.Position.Z:F6}.");
}
+ ///
+ /// Control for the outside-support capture above. Moving the same center
+ /// 8.8 cm inward puts it 0.20 m beyond the tread edge, inside retail's
+ /// 0.24 m half-radius support allowance. The side-wall response must then
+ /// preserve meaningful uphill progress rather than treating every contact
+ /// near the edge as unsupported.
+ ///
+ [Fact]
+ public void OutdoorStairs_SideWallContact_InsideHalfRadius_AdvancesUphill()
+ {
+ var engine = BuildStairEngine();
+ var body = CapturedSideWallBody(x: 132.95f);
+
+ ResolveResult result = engine.ResolveWithTransition(
+ currentPos: body.Position,
+ targetPos: body.Position + new Vector3(0.30007935f, 0.8837738f, 0f),
+ cellId: StairCellId,
+ sphereRadius: 0.48f,
+ sphereHeight: 1.835f,
+ stepUpHeight: 0.6f,
+ stepDownHeight: 1.5f,
+ isOnGround: true,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x01000000u);
+
+ _out.WriteLine(
+ $"inside-support out=({result.Position.X:F6},{result.Position.Y:F6},"
+ + $"{result.Position.Z:F6}) collision={result.CollisionNormalValid} "
+ + $"normal=({result.CollisionNormal.X:F3},{result.CollisionNormal.Y:F3},"
+ + $"{result.CollisionNormal.Z:F3})");
+
+ Assert.True(result.Position.Y > body.Position.Y + 0.25f,
+ $"Inside-half-radius support failed to preserve meaningful uphill motion: "
+ + $"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
+ Assert.True(result.Position.Z > body.Position.Z + 0.10f,
+ $"Inside-half-radius support failed to climb the tread: "
+ + $"{body.Position.Z:F6} -> {result.Position.Z:F6}.");
+ }
+
+ private static PhysicsBody CapturedSideWallBody(float x)
+ {
+ PhysicsBody body = GroundedOnTread();
+ body.Position = new Vector3(x, 75.53931f, 59.608147f);
+ var tread = new Plane(
+ new Vector3(3.2782555e-07f, -0.62469506f, 0.78086877f),
+ 0.75193405f);
+ body.ContactPlane = tread;
+ body.WalkablePlane = tread;
+
+ // GroundedOnTread describes k=4. The live #271 capture is on k=1:
+ // three authored 0.5 m Y / 0.4 m Z stair increments lower.
+ Vector3 delta = new(0f, 1.5f, 1.2f);
+ Vector3[] vertices = Assert.IsType(body.WalkableVertices);
+ body.WalkableVertices = Array.ConvertAll(vertices, vertex => vertex - delta);
+ return body;
+ }
+
private static string SolutionRoot()
{
var dir = AppContext.BaseDirectory;
diff --git a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs
index 66abfcc7..73692eb8 100644
--- a/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs
@@ -44,7 +44,10 @@ public sealed class Issue273HoltburgTightGapReplayTests
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f),
];
- private static PhysicsEngine BuildEngine()
+ private static PhysicsEngine BuildEngine(
+ bool preparedFlat = false,
+ bool includeShell = true,
+ bool includeBlockingPost = true)
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
@@ -57,23 +60,38 @@ public sealed class Issue273HoltburgTightGapReplayTests
"issue273",
"0x01000F69.gfxobj.json");
Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}");
- cache.RegisterGfxObjForTest(
- ShellGfxObj,
- GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath)));
+ GfxObjPhysics physics = GfxObjDumpSerializer.Hydrate(
+ GfxObjDumpSerializer.Read(dumpPath));
+ if (preparedFlat)
+ {
+ cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
+ cache.CacheGfxObj(
+ ShellGfxObj,
+ FlatCollisionAssetBuilder.FlattenGfxObj(physics));
+ }
+ else
+ {
+ cache.RegisterGfxObjForTest(ShellGfxObj, physics);
+ }
// The shell is registered through retail's building channel, not as a
// shadow object. Its one portal is irrelevant to this exterior sweep.
- cache.CacheBuilding(
- Cell,
- Array.Empty(),
- BuildingTransform,
- ShellGfxObj);
+ if (includeShell)
+ {
+ cache.CacheBuilding(
+ Cell,
+ Array.Empty(),
+ BuildingTransform,
+ ShellGfxObj);
+ }
- // Terrain is deliberately below the shell. The player stands on the
- // shell's authored z=2 ledge (world z=96), not synthetic terrain.
+ // Terrain is deliberately below the shell in the real fixture. The
+ // no-shell counterfactual substitutes a featureless floor at the same
+ // height so it isolates the removed shell wall/ledge while preserving
+ // grounded movement around the remaining post.
var heights = new byte[81];
var heightTable = new float[256];
- Array.Fill(heightTable, -1000f);
+ Array.Fill(heightTable, includeShell ? -1000f : 96f);
engine.AddLandblock(
Landblock,
new TerrainSurface(heights, heightTable),
@@ -82,11 +100,14 @@ public sealed class Issue273HoltburgTightGapReplayTests
0f,
0f);
- RegisterPost(
- engine,
- 0xCA9B4027u,
- new Vector3(160.173f, 34.487f, 95.975f),
- radius: 0.282f);
+ if (includeBlockingPost)
+ {
+ RegisterPost(
+ engine,
+ 0xCA9B4027u,
+ new Vector3(160.173f, 34.487f, 95.975f),
+ radius: 0.282f);
+ }
RegisterPost(
engine,
0xCA9B402Eu,
@@ -185,9 +206,66 @@ public sealed class Issue273HoltburgTightGapReplayTests
[Fact]
public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding()
{
- PhysicsEngine engine = BuildEngine();
+ ReplayFrame[] trace = RunCapturedReplay(BuildEngine());
+ Vector3 position = trace[^1].Result.Position;
+
+ // The captured broken run reached (163.234, 36.982) by this point,
+ // already beyond the post and sliding along the building. Retail
+ // blocks the passage before the player can cross the post's Y.
+ Assert.True(
+ position.Y < 34.487f,
+ $"Player squeezed through the retail-blocked gap: "
+ + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3}).");
+ }
+
+ [Fact]
+ public void CapturedRun_PreparedFlatMatchesGraphForEveryFrame()
+ {
+ ReplayFrame[] graph = RunCapturedReplay(BuildEngine());
+ ReplayFrame[] preparedFlat = RunCapturedReplay(
+ BuildEngine(preparedFlat: true));
+
+ Assert.Equal(graph.Length, preparedFlat.Length);
+ for (int frame = 0; frame < graph.Length; frame++)
+ AssertFrameBitwise(graph[frame], preparedFlat[frame], frame);
+ }
+
+ [Theory]
+ [InlineData(false, true, "shell")]
+ [InlineData(true, false, "blocking post")]
+ public void CapturedRun_RequiresBothShellAndBlockingPost(
+ bool includeShell,
+ bool includeBlockingPost,
+ string omittedGeometry)
+ {
+ PhysicsEngine engine = BuildEngine(
+ includeShell: includeShell,
+ includeBlockingPost: includeBlockingPost);
+ PhysicsBody body = GroundedBody(new Vector3(160.016f, 33.562f, 96.005f));
+ if (!includeShell)
+ {
+ // Do not retain the removed shell through the body's remembered
+ // support polygon. The contact plane keeps the counterfactual at
+ // the captured height while the shell geometry itself is absent.
+ body.WalkablePolygonValid = false;
+ body.WalkableVertices = null;
+ }
+
+ ReplayFrame[] trace = RunCapturedReplay(engine, body);
+ Vector3 final = trace[^1].Result.Position;
+
+ Assert.True(
+ final.Y > 34.487f,
+ $"Omitting the {omittedGeometry} still reproduced the complete "
+ + $"fixture's block: final=({final.X:F3},{final.Y:F3},{final.Z:F3}).");
+ }
+
+ private ReplayFrame[] RunCapturedReplay(
+ PhysicsEngine engine,
+ PhysicsBody? body = null)
+ {
Vector3 position = new(160.016f, 33.562f, 96.005f);
- var body = GroundedBody(position);
+ body ??= GroundedBody(position);
uint cell = Cell;
// First frame is copied verbatim from the live capture. Subsequent
@@ -204,6 +282,7 @@ public sealed class Issue273HoltburgTightGapReplayTests
new(0.727f, 0.585f, 0f),
new(0.728f, 0.585f, 0f),
];
+ var trace = new ReplayFrame[offsets.Length];
for (int frame = 0; frame < offsets.Length; frame++)
{
@@ -232,17 +311,118 @@ public sealed class Issue273HoltburgTightGapReplayTests
position = result.Position;
cell = result.CellId;
body.Position = position;
+ trace[frame] = new ReplayFrame(
+ result,
+ body.TransientState,
+ body.ContactPlaneValid,
+ body.ContactPlane,
+ body.ContactPlaneCellId,
+ body.ContactPlaneIsWater,
+ body.WalkablePolygonValid,
+ body.WalkablePlane,
+ body.WalkableUp,
+ body.SlidingNormal);
}
- // The captured broken run reached (163.234, 36.982) by this point,
- // already beyond the post and sliding along the building. Retail
- // blocks the passage before the player can cross the post's Y.
- Assert.True(
- position.Y < 34.487f,
- $"Player squeezed through the retail-blocked gap: "
- + $"final=({position.X:F3},{position.Y:F3},{position.Z:F3}).");
+ return trace;
}
+ private static void AssertFrameBitwise(
+ ReplayFrame expected,
+ ReplayFrame actual,
+ int frame)
+ {
+ string context = $"frame {frame}";
+ AssertVectorBitwise(expected.Result.Position, actual.Result.Position, context);
+ Assert.Equal(expected.Result.CellId, actual.Result.CellId);
+ Assert.Equal(expected.Result.IsOnGround, actual.Result.IsOnGround);
+ Assert.Equal(
+ expected.Result.CollisionNormalValid,
+ actual.Result.CollisionNormalValid);
+ AssertVectorBitwise(
+ expected.Result.CollisionNormal,
+ actual.Result.CollisionNormal,
+ context);
+ Assert.Equal(expected.Result.Ok, actual.Result.Ok);
+ AssertQuaternionBitwise(
+ expected.Result.Orientation,
+ actual.Result.Orientation,
+ context);
+ Assert.Equal(expected.Result.InContact, actual.Result.InContact);
+ Assert.Equal(expected.Result.OnWalkable, actual.Result.OnWalkable);
+ AssertPlaneBitwise(
+ expected.Result.ContactPlane,
+ actual.Result.ContactPlane,
+ context);
+ Assert.Equal(
+ expected.Result.ContactPlaneCellId,
+ actual.Result.ContactPlaneCellId);
+ Assert.Equal(
+ expected.Result.ContactPlaneIsWater,
+ actual.Result.ContactPlaneIsWater);
+ Assert.Equal(expected.TransientState, actual.TransientState);
+ Assert.Equal(expected.ContactPlaneValid, actual.ContactPlaneValid);
+ AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context);
+ Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId);
+ Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
+ Assert.Equal(expected.WalkablePolygonValid, actual.WalkablePolygonValid);
+ AssertPlaneBitwise(expected.WalkablePlane, actual.WalkablePlane, context);
+ AssertVectorBitwise(expected.WalkableUp, actual.WalkableUp, context);
+ AssertVectorBitwise(expected.SlidingNormal, actual.SlidingNormal, context);
+ }
+
+ private static void AssertVectorBitwise(
+ Vector3 expected,
+ Vector3 actual,
+ string context)
+ {
+ AssertFloatBitwise(expected.X, actual.X, $"{context}.X");
+ AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y");
+ AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z");
+ }
+
+ private static void AssertQuaternionBitwise(
+ Quaternion expected,
+ Quaternion actual,
+ string context)
+ {
+ AssertFloatBitwise(expected.X, actual.X, $"{context}.X");
+ AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y");
+ AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z");
+ AssertFloatBitwise(expected.W, actual.W, $"{context}.W");
+ }
+
+ private static void AssertPlaneBitwise(
+ Plane expected,
+ Plane actual,
+ string context)
+ {
+ AssertVectorBitwise(expected.Normal, actual.Normal, $"{context}.Normal");
+ AssertFloatBitwise(expected.D, actual.D, $"{context}.D");
+ }
+
+ private static void AssertFloatBitwise(
+ float expected,
+ float actual,
+ string context)
+ => Assert.True(
+ BitConverter.SingleToInt32Bits(expected)
+ == BitConverter.SingleToInt32Bits(actual),
+ $"{context}: expected 0x{BitConverter.SingleToInt32Bits(expected):X8}, "
+ + $"actual 0x{BitConverter.SingleToInt32Bits(actual):X8}");
+
+ private readonly record struct ReplayFrame(
+ ResolveResult Result,
+ TransientStateFlags TransientState,
+ bool ContactPlaneValid,
+ Plane ContactPlane,
+ uint ContactPlaneCellId,
+ bool ContactPlaneIsWater,
+ bool WalkablePolygonValid,
+ Plane WalkablePlane,
+ Vector3 WalkableUp,
+ Vector3 SlidingNormal);
+
private static string SolutionRoot()
{
string? directory = AppContext.BaseDirectory;