acdream/tests/AcDream.Core.Tests/Physics/Issue273HoltburgTightGapReplayTests.cs

259 lines
9 KiB
C#

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}.");
}
}