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

240 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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), k4 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 &gt; 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 = 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);
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}.");
}
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);
}
}