test(physics): harden tight-gap collision controls
This commit is contained in:
parent
c24bc571cf
commit
10b55d7485
2 changed files with 265 additions and 31 deletions
|
|
@ -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}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<Vector3[]>(body.WalkableVertices);
|
||||
body.WalkableVertices = Array.ConvertAll(vertices, vertex => vertex - delta);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string SolutionRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
|
|
|
|||
|
|
@ -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<BldPortalInfo>(),
|
||||
BuildingTransform,
|
||||
ShellGfxObj);
|
||||
if (includeShell)
|
||||
{
|
||||
cache.CacheBuilding(
|
||||
Cell,
|
||||
Array.Empty<BldPortalInfo>(),
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue