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; /// /// #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 /// EdgeSlideAfterStepDownFailedPrecipiceSlide fabricates the /// tread-edge normal (0,±0.78,±0.62), which SetSlidingNormal /// horizontalises to (0,±1,0) — absorbing the +Y up-stairs motion into /// a pure sideways slide (the #137 / TS-4 family). A jump's +Z clears it. /// /// /// Fixtures captured live this session: the player pins at world /// (131.71, 77.914, 61.485) with slidingNormal=(0,1,0) and a /// re-fabricated collisionNormal=(0,0.78,0.62); the tread the player is /// grounded on has world verts (132.75,77.495,61.015)…. The step-box /// geometry is hydrated from the captured gfxobj dump /// (Fixtures/issue185/0x01000AC5.gfxobj.json) so the test is /// self-contained (no dat) and CI-runnable. /// /// /// /// The step origins march +0.5 world-Y / +0.4 world-Z (matching every observed /// [resolve-bldg] origin: 75.2/75.7/76.2/76.7/77.2). The player's tread /// (k=4) is pinned to origin (132.0, 77.245, 60.415) by the captured /// walkable verts. k=0..7 are registered to span the jam seam + the /// continuation the player must climb onto. /// /// 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(), portals: Array.Empty(), 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, }; } /// /// 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. /// [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)."); } 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); } }