From 10b55d7485dad510bf90810d314fc0b06fbe5580 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:17:35 +0200 Subject: [PATCH] test(physics): harden tight-gap collision controls --- .../Issue185OutdoorStairsSeamReplayTests.cs | 64 ++++- .../Issue273HoltburgTightGapReplayTests.cs | 232 ++++++++++++++++-- 2 files changed, 265 insertions(+), 31 deletions(-) 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;