294 lines
13 KiB
C#
294 lines
13 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Numerics;
|
||
using AcDream.Core.Physics;
|
||
using Xunit;
|
||
using Xunit.Abstractions;
|
||
using Plane = System.Numerics.Plane;
|
||
|
||
namespace AcDream.Core.Tests.Physics;
|
||
|
||
/// <summary>
|
||
/// #185 outdoor-stairs phantom (2026-07-08) — dat-free reproduction of the
|
||
/// house-on-stilts staircase jam. The stairs are a continuous, COPLANAR
|
||
/// 38.7° ramp built from stacked step-box objects (gfxObj 0x01000AC5); the
|
||
/// tread quads abut at 0.5 m seams that fall on object boundaries. Walking
|
||
/// up, at one seam the grounded forward move loses its contact plane and the
|
||
/// step-down recovery cannot reach the coplanar (at-level) continuation, so
|
||
/// <c>EdgeSlideAfterStepDownFailed</c> → <c>PrecipiceSlide</c> fabricates the
|
||
/// tread-edge normal <c>(0,±0.78,±0.62)</c>, which <c>SetSlidingNormal</c>
|
||
/// horizontalises to <c>(0,±1,0)</c> — absorbing the +Y up-stairs motion into
|
||
/// a pure sideways slide (the #137 / TS-4 family). A jump's +Z clears it.
|
||
///
|
||
/// <para>
|
||
/// Fixtures captured live this session: the player pins at world
|
||
/// <c>(131.71, 77.914, 61.485)</c> with <c>slidingNormal=(0,1,0)</c> and a
|
||
/// re-fabricated <c>collisionNormal=(0,0.78,0.62)</c>; the tread the player is
|
||
/// grounded on has world verts <c>(132.75,77.495,61.015)…</c>. The step-box
|
||
/// geometry is hydrated from the captured gfxobj dump
|
||
/// (<c>Fixtures/issue185/0x01000AC5.gfxobj.json</c>) so the test is
|
||
/// self-contained (no dat) and CI-runnable.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The step origins march +0.5 world-Y / +0.4 world-Z (matching every observed
|
||
/// <c>[resolve-bldg]</c> origin: 75.2/75.7/76.2/76.7/77.2). The player's tread
|
||
/// (k=4) is pinned to origin <c>(132.0, 77.245, 60.415)</c> by the captured
|
||
/// walkable verts. k=0..7 are registered to span the jam seam + the
|
||
/// continuation the player must climb onto.
|
||
/// </para>
|
||
/// </summary>
|
||
public class Issue185OutdoorStairsSeamReplayTests
|
||
{
|
||
private readonly ITestOutputHelper _out;
|
||
public Issue185OutdoorStairsSeamReplayTests(ITestOutputHelper output) => _out = output;
|
||
|
||
private const uint StairCellId = 0xF682002Cu; // outdoor landcell (low16 = 0x2C < 0x100)
|
||
private const uint StairLandblock = 0xF6820000u;
|
||
private const uint StepGfxObjId = 0x01000AC5u;
|
||
private const int StepCount = 8;
|
||
|
||
// A +90°-about-Z-rotated step-box maps its local tread normal
|
||
// (-0.625,0,0.781) → world (0,-0.625,0.781) (the captured tread plane).
|
||
private static readonly Quaternion StepRot =
|
||
Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
|
||
|
||
// k=4 tread lands at the captured walkable verts when the step origin is
|
||
// (132.0, 77.245, 60.415); step k origin = this + k·(0, 0.5, 0.4), k−4 offset.
|
||
private static readonly Vector3 Step0Origin = new(132.0f, 75.245f, 58.815f);
|
||
|
||
private static PhysicsEngine BuildStairEngine()
|
||
{
|
||
var cache = new PhysicsDataCache();
|
||
var engine = new PhysicsEngine { DataCache = cache };
|
||
|
||
// Hydrate the step-box collision geometry from the captured dump.
|
||
var dumpPath = Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
|
||
"Fixtures", "issue185", "0x01000AC5.gfxobj.json");
|
||
Assert.True(File.Exists(dumpPath), $"Missing fixture: {dumpPath}");
|
||
var physics = GfxObjDumpSerializer.Hydrate(GfxObjDumpSerializer.Read(dumpPath));
|
||
cache.RegisterGfxObjForTest(StepGfxObjId, physics);
|
||
float bspR = physics.BoundingSphere?.Radius ?? 1.06f;
|
||
|
||
// Stub landblock (terrain far below Z=61) so the outdoor context resolves
|
||
// without the player's grounding ever seeing terrain — it stands on the treads.
|
||
var heights = new byte[81];
|
||
var heightTable = new float[256];
|
||
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||
engine.AddLandblock(
|
||
landblockId: StairLandblock,
|
||
terrain: new TerrainSurface(heights, heightTable),
|
||
cells: Array.Empty<CellSurface>(),
|
||
portals: Array.Empty<PortalPlane>(),
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f);
|
||
|
||
for (int k = 0; k < StepCount; k++)
|
||
{
|
||
var origin = Step0Origin + new Vector3(0f, 0.5f * k, 0.4f * k);
|
||
engine.ShadowObjects.Register(
|
||
entityId: 0xF6820100u + (uint)k,
|
||
gfxObjId: StepGfxObjId,
|
||
worldPos: origin,
|
||
rotation: StepRot,
|
||
radius: bspR,
|
||
worldOffsetX: 0f,
|
||
worldOffsetY: 0f,
|
||
landblockId: StairLandblock,
|
||
collisionType: ShadowCollisionType.BSP,
|
||
scale: 1.0f,
|
||
seedCellId: StairCellId);
|
||
}
|
||
|
||
return engine;
|
||
}
|
||
|
||
private static PhysicsBody GroundedOnTread()
|
||
{
|
||
var tread = new Plane(new Vector3(0f, -0.62469506f, 0.78086877f), 0.765995f);
|
||
return new PhysicsBody
|
||
{
|
||
Position = new Vector3(131.72375f, 77.49132f, 61.146755f),
|
||
Orientation = Quaternion.Identity,
|
||
ContactPlaneValid = true,
|
||
ContactPlane = tread,
|
||
ContactPlaneCellId = StairCellId,
|
||
WalkablePolygonValid = true,
|
||
WalkablePlane = tread,
|
||
WalkableUp = Vector3.UnitZ,
|
||
WalkableVertices = new[]
|
||
{
|
||
new Vector3(132.75f, 77.495f, 61.015f),
|
||
new Vector3(131.25f, 77.495f, 61.015f),
|
||
new Vector3(131.25f, 76.995f, 60.615f),
|
||
new Vector3(132.75f, 76.995f, 60.615f),
|
||
},
|
||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drive a held-forward run up the ramp (flat +Y target each tick, physics
|
||
/// climbs Z via contact projection — as the movement controller sends it).
|
||
/// The player must climb PAST the tread seam (Y > 78.1); pre-fix it pins
|
||
/// at ~77.9 and persists a horizontal sliding normal = the wedge.
|
||
/// </summary>
|
||
[Fact]
|
||
public void OutdoorStairs_ForwardRun_ClimbsPastSeam_NoWedge()
|
||
{
|
||
var engine = BuildStairEngine();
|
||
var body = GroundedOnTread();
|
||
|
||
var pos = body.Position;
|
||
uint cell = StairCellId;
|
||
ResolveResult r = default;
|
||
|
||
for (int i = 0; i < 12; i++)
|
||
{
|
||
var target = new Vector3(pos.X, pos.Y + 0.2f, pos.Z); // flat +Y; physics climbs
|
||
r = engine.ResolveWithTransition(
|
||
currentPos: pos,
|
||
targetPos: target,
|
||
cellId: cell,
|
||
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(
|
||
$"f{i}: out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) cell=0x{r.CellId:X8} " +
|
||
$"cnV={r.CollisionNormalValid} cn=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2}) " +
|
||
$"sliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " +
|
||
$"sN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})");
|
||
|
||
pos = r.Position;
|
||
cell = r.CellId;
|
||
body.Position = pos;
|
||
}
|
||
|
||
Assert.True(pos.Y > 78.10f,
|
||
$"Player must climb past the tread seam (reached Y={pos.Y:F3}); pinned at ~77.9 = the " +
|
||
$"#185 fabricated-precipice wedge (PrecipiceSlide horizontal sliding normal absorbs +Y).");
|
||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||
"A continuous walkable ramp seam must not persist a horizontal sliding normal (#137 family).");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #271 live capture, quantum 310: a forward/uphill displacement that also
|
||
/// 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_DoesNotReverseDownhill()
|
||
{
|
||
var engine = BuildStairEngine();
|
||
var body = CapturedSideWallBody(x: 133.03775f);
|
||
|
||
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(
|
||
$"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.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: " +
|
||
$"{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;
|
||
while (!string.IsNullOrEmpty(dir))
|
||
{
|
||
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
||
return dir;
|
||
dir = Path.GetDirectoryName(dir);
|
||
}
|
||
throw new InvalidOperationException(
|
||
"Could not locate AcDream.slnx from " + AppContext.BaseDirectory);
|
||
}
|
||
}
|