fix(physics): enforce retail step-down support radius (#273)
This commit is contained in:
parent
4dd40ad8fe
commit
c24bc571cf
9 changed files with 2117 additions and 24 deletions
1626
tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
Normal file
1626
tests/AcDream.Core.Tests/Fixtures/issue273/0x01000F69.gfxobj.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -179,12 +179,18 @@ public class Issue185OutdoorStairsSeamReplayTests
|
|||
|
||||
/// <summary>
|
||||
/// #271 live capture, quantum 310: a forward/uphill displacement that also
|
||||
/// presses into the staircase's side wall must keep its uphill tangent.
|
||||
/// Pre-fix the composite retry path reversed that tangent, moving from
|
||||
/// Y=75.539 to Y=75.199 and rapidly carrying the player back down the stairs.
|
||||
/// presses into the staircase's side wall must never reverse downhill.
|
||||
/// Pre-fix the composite retry path moved from Y=75.539 to Y=75.199 and
|
||||
/// rapidly carried the player back down the stairs.
|
||||
///
|
||||
/// The captured center is 0.288 m beyond the tread's side edge. Retail's
|
||||
/// SPHEREPATH::check_walkables uses a 0.24 m half-radius support sphere, so
|
||||
/// stopping at this exact side-wall position is valid; advancing farther
|
||||
/// uphill is not. The regression invariant is therefore no downhill motion,
|
||||
/// not mandatory forward progress beyond retail's support boundary.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OutdoorStairs_SideWallContact_DoesNotReverseUphillTangent()
|
||||
public void OutdoorStairs_SideWallContact_DoesNotReverseDownhill()
|
||||
{
|
||||
var engine = BuildStairEngine();
|
||||
var body = GroundedOnTread();
|
||||
|
|
@ -211,8 +217,8 @@ public class Issue185OutdoorStairsSeamReplayTests
|
|||
$"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,
|
||||
$"Side-wall response failed to preserve meaningful uphill motion: " +
|
||||
Assert.True(result.Position.Y >= body.Position.Y - 0.001f,
|
||||
$"Side-wall response reversed the intended uphill motion: " +
|
||||
$"{body.Position.Y:F6} -> {result.Position.Y:F6}.");
|
||||
Assert.True(result.Position.Z >= body.Position.Z - 0.001f,
|
||||
$"Side-wall response dropped the grounded player downhill: " +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #273 — exact Holtburg tight-gap replay captured live on 2026-07-31.
|
||||
/// The player presses between building shell 0x01000F69 and the timber post
|
||||
/// at 0xCA9B4027. Retail blocks this passage; before the fix ACDream lets the
|
||||
/// post slide feed a displaced step-down probe into precipice-slide, which
|
||||
/// carries the player around the post and along the building's outer edge.
|
||||
///
|
||||
/// The shell collision is a self-contained dump of the installed DAT's real
|
||||
/// PhysicsBSP. Post dimensions, building frame, player Setup spheres, and
|
||||
/// movement frames are copied from the live trace.
|
||||
/// </summary>
|
||||
public sealed class Issue273HoltburgTightGapReplayTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public Issue273HoltburgTightGapReplayTests(ITestOutputHelper output)
|
||||
=> _output = output;
|
||||
|
||||
private const uint Landblock = 0xA9B40000u;
|
||||
private const uint Cell = 0xA9B40032u;
|
||||
private const uint ShellGfxObj = 0x01000F69u;
|
||||
private const uint PlayerEntity = 0x000F4243u;
|
||||
|
||||
private static readonly Vector3 BuildingOrigin = new(158.178f, 37.7055f, 94f);
|
||||
private static readonly Quaternion BuildingRotation =
|
||||
Quaternion.Normalize(new Quaternion(0f, 0f, -0.343045f, 0.939319f));
|
||||
private static readonly Matrix4x4 BuildingTransform =
|
||||
Matrix4x4.CreateFromQuaternion(BuildingRotation)
|
||||
* Matrix4x4.CreateTranslation(BuildingOrigin);
|
||||
|
||||
private static readonly ImmutableArray<FlatCollisionSphere> PlayerSpheres =
|
||||
[
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.480f),
|
||||
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.480f),
|
||||
];
|
||||
|
||||
private static PhysicsEngine BuildEngine()
|
||||
{
|
||||
var cache = new PhysicsDataCache();
|
||||
var engine = new PhysicsEngine { DataCache = cache };
|
||||
|
||||
string dumpPath = Path.Combine(
|
||||
SolutionRoot(),
|
||||
"tests",
|
||||
"AcDream.Core.Tests",
|
||||
"Fixtures",
|
||||
"issue273",
|
||||
"0x01000F69.gfxobj.json");
|
||||
Assert.True(File.Exists(dumpPath), $"Missing issue #273 fixture: {dumpPath}");
|
||||
cache.RegisterGfxObjForTest(
|
||||
ShellGfxObj,
|
||||
GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath)));
|
||||
|
||||
// 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);
|
||||
|
||||
// Terrain is deliberately below the shell. The player stands on the
|
||||
// shell's authored z=2 ledge (world z=96), not synthetic terrain.
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
Array.Fill(heightTable, -1000f);
|
||||
engine.AddLandblock(
|
||||
Landblock,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
|
||||
RegisterPost(
|
||||
engine,
|
||||
0xCA9B4027u,
|
||||
new Vector3(160.173f, 34.487f, 95.975f),
|
||||
radius: 0.282f);
|
||||
RegisterPost(
|
||||
engine,
|
||||
0xCA9B402Eu,
|
||||
new Vector3(158.282f, 34.610f, 95.975f),
|
||||
radius: 0.282f);
|
||||
RegisterPost(
|
||||
engine,
|
||||
0xCA9B402Fu,
|
||||
new Vector3(157.952f, 32.239f, 96f),
|
||||
radius: 0.600f);
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static void RegisterPost(
|
||||
PhysicsEngine engine,
|
||||
uint entityId,
|
||||
Vector3 basePosition,
|
||||
float radius)
|
||||
{
|
||||
engine.ShadowObjects.Register(
|
||||
entityId,
|
||||
gfxObjId: 0u,
|
||||
worldPos: basePosition,
|
||||
rotation: Quaternion.Identity,
|
||||
radius,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: Landblock,
|
||||
collisionType: ShadowCollisionType.Cylinder,
|
||||
cylHeight: 5.564f,
|
||||
state: 0u,
|
||||
seedCellId: Cell,
|
||||
isStatic: true);
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody(Vector3 position)
|
||||
{
|
||||
Vector3[] localWalkable =
|
||||
[
|
||||
new(4f, 7.25f, 2f),
|
||||
new(3.3f, 6.5003f, 2f),
|
||||
new(3.3f, -2.0157f, 2f),
|
||||
new(4f, -4.7f, 2f),
|
||||
];
|
||||
var worldWalkable = new Vector3[localWalkable.Length];
|
||||
for (int i = 0; i < localWalkable.Length; i++)
|
||||
worldWalkable[i] = Vector3.Transform(localWalkable[i], BuildingTransform);
|
||||
|
||||
var floor = new Plane(Vector3.UnitZ, -96f);
|
||||
return new PhysicsBody
|
||||
{
|
||||
Position = position,
|
||||
Orientation = Quaternion.Identity,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = floor,
|
||||
ContactPlaneCellId = Cell,
|
||||
WalkablePolygonValid = true,
|
||||
WalkablePlane = floor,
|
||||
WalkableUp = Vector3.UnitZ,
|
||||
WalkableVertices = worldWalkable,
|
||||
TransientState =
|
||||
TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.239f, true)]
|
||||
[InlineData(1.241f, false)]
|
||||
public void RetailHalfRadiusSupport_RejectsCenterBeyondQuarterMeterEdge(
|
||||
float centerX,
|
||||
bool expected)
|
||||
{
|
||||
Vector3[] floor =
|
||||
[
|
||||
new(0f, 0f, 0f),
|
||||
new(1f, 0f, 0f),
|
||||
new(1f, 1f, 0f),
|
||||
new(0f, 1f, 0f),
|
||||
];
|
||||
|
||||
// The player's 0.48 m foot sphere becomes a 0.24 m support sphere in
|
||||
// SPHEREPATH::check_walkables. Just inside that boundary is supported;
|
||||
// just outside it is not, even though the full movement sphere still
|
||||
// overlaps the floor polygon.
|
||||
bool supported = BSPQuery.CheckWalkableSupport(
|
||||
new Plane(Vector3.UnitZ, 0f),
|
||||
floor,
|
||||
new Vector3(centerX, 0.5f, 0.48f),
|
||||
supportRadius: 0.24f,
|
||||
Vector3.UnitZ);
|
||||
|
||||
Assert.Equal(expected, supported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapturedRun_DoesNotSqueezeBetweenPostAndBuilding()
|
||||
{
|
||||
PhysicsEngine engine = BuildEngine();
|
||||
Vector3 position = new(160.016f, 33.562f, 96.005f);
|
||||
var body = GroundedBody(position);
|
||||
uint cell = Cell;
|
||||
|
||||
// First frame is copied verbatim from the live capture. Subsequent
|
||||
// held-forward frames use the stable displacement visible in that
|
||||
// same trace after input acceleration settles.
|
||||
Vector3[] offsets =
|
||||
[
|
||||
new(1.396f, 1.123f, 0f),
|
||||
new(0.727f, 0.584f, 0f),
|
||||
new(0.624f, 0.501f, 0f),
|
||||
new(0.727f, 0.585f, 0f),
|
||||
new(0.728f, 0.585f, 0f),
|
||||
new(0.727f, 0.585f, 0f),
|
||||
new(0.727f, 0.585f, 0f),
|
||||
new(0.728f, 0.585f, 0f),
|
||||
];
|
||||
|
||||
for (int frame = 0; frame < offsets.Length; frame++)
|
||||
{
|
||||
ResolveResult result = engine.ResolveWithTransition(
|
||||
currentPos: position,
|
||||
targetPos: position + offsets[frame],
|
||||
cellId: cell,
|
||||
sphereRadius: 0.48f,
|
||||
sphereHeight: 1.835f,
|
||||
stepUpHeight: 0.6f,
|
||||
stepDownHeight: 1.5f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: PlayerEntity,
|
||||
sphereList: PlayerSpheres,
|
||||
sphereScale: 1f);
|
||||
|
||||
_output.WriteLine(
|
||||
$"f{frame}: in=({position.X:F3},{position.Y:F3},{position.Z:F3}) "
|
||||
+ $"out=({result.Position.X:F3},{result.Position.Y:F3},{result.Position.Z:F3}) "
|
||||
+ $"hit={result.CollisionNormalValid} "
|
||||
+ $"normal=({result.CollisionNormal.X:F3},"
|
||||
+ $"{result.CollisionNormal.Y:F3},{result.CollisionNormal.Z:F3})");
|
||||
|
||||
position = result.Position;
|
||||
cell = result.CellId;
|
||||
body.Position = 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}).");
|
||||
}
|
||||
|
||||
private static string SolutionRoot()
|
||||
{
|
||||
string? directory = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
|
||||
return directory;
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Could not locate AcDream.slnx from {AppContext.BaseDirectory}.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue