using System; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// #345 acceptance: a grounded mover walking at an angle into a TOO-STEEP /// terrain face must GLIDE laterally along it — faster the more angled the /// approach — while a perpendicular approach stops. The user's retail /// observation ("it glides, faster the more angle") is the axiom; the live /// cdb profile (594 edge_slide/cliff_slide lockstep per run, step_up=0) /// and the byte-pin in /// docs/research/2026-08-08-345-d0-branch-pin.md establish the /// mechanism: retail's validate_walkable below-plane arm returns OK /// (not Adjusted) when its guard fails on a grounded-OnWalkable mover /// against a too-steep plane (0x0050d1b9 jumps past the push AND past the /// var_1c = 3 at 0x0050d249, leaving the 0x0050d025 init of OK), so /// the insert proceeds, the step-down phase fails on the steep landing, /// and the edge-slide family produces the per-tick lateral glide. /// /// /// Discriminating fixture (the live #345 topology): the flat and /// steep triangles share ONE terrain cell's diagonal. Cell (3,3) of the /// synthetic landblock splits SW→NE (FSplitNESW), so raising only its TL /// post to 32 m leaves the below-diagonal triangle {BL,BR,TR} flat while /// the above-diagonal triangle {BL,TR,TL} carries the whole rise: normal /// (0.469, -0.469, 0.469-normalized) with N.z ≈ 0.469 — well below the /// walkable threshold (~0.664). Because both triangles live in the SAME /// cell, the primary-phase terrain sample validates the steep plane the /// moment the check position crosses the diagonal — the exact spot the /// pre-fix code dead-looped (Adjusted with no push, byte-identical /// retries). A cell-BOUNDARY face does NOT reproduce that loop (the /// cell-scoped primary sample skips a triangle outside the primary cell), /// which is why the supplementary boundary test below is not the /// discriminator. /// /// public sealed class Issue345SteepSlopeGlideTests { private const float DxyPerTick = 0.23f; // the captured per-tick request private const int Ticks = 30; // Cell (3,3): x,y in [72,96]. The diagonal runs BL(72,72) → TR(96,96) // (the line y = x). Start on the flat triangle ~0.42 m perpendicular // from the diagonal, mid-cell, so every approach engages the steep // face within a couple of ticks. private const float StartX = 80.4f; private const float StartY = 79.8f; // In-cell face frame: the steep face's horizontal trace is the // diagonal, direction (1,1)/√2; the into-face perpendicular (from the // flat side toward the steep side) is (-1,1)/√2. private static readonly Vector2 Lateral = new(0.70710678f, 0.70710678f); private static readonly Vector2 IntoFace = new(-0.70710678f, 0.70710678f); [Fact] public void Angled45Approach_GlidesAlongTheDiagonal() { var (finalPos, stuckTicks) = RunApproach(angleFromPerpendicularDeg: 45f); float lateral = LateralAdvance(finalPos); Assert.True(lateral > 1.0f, $"expected the lateral component to survive against the " + $"too-steep face (the retail glide), got only {lateral:F3} m " + $"along the face over {Ticks} ticks (final=" + $"{finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})"); // The glide must not secretly climb the steep face — and the new // OK return is specifically the "no push-out" path, so the mover // must not sink below the flat triangle (z=0) either. Assert.True(finalPos.Z < 1.0f, $"expected the mover to stay at the base of the too-steep " + $"face, but Z climbed to {finalPos.Z:F3}"); Assert.True(finalPos.Z > -0.05f, $"expected the mover to stay on the flat surface (z=0), but " + $"it sank to Z={finalPos.Z:F3}"); // The pre-fix dead loop spent EVERY post-crossing tick stuck // (resolve output identical to input against a nonzero request; // 28 of 30 here). The fixed glide alternates: the arming tick // absorbs the request while edge-slide sets the sliding normal, // the next tick consumes it and moves (14 of 30 stuck). The // alternation IS retail's own shape — confirmed by the #347 // round-2 cdb capture (345-glide-stacks.cdb.log: ~1.5 edge_slide // per find_transitional_position during a live glide, the // alternation's exact signature) — so this range pins it from // both sides. Stuck ticks are counted from positions, not the // (temporary) transit-fail probe, so the assertion survives the // probe family's eventual strip; the lower bound keeps it from // going vacuous if the fixture stops engaging the face at all. Assert.InRange(stuckTicks, 1, Ticks / 2 + 2); } [Fact] public void SteeperApproachAngle_YieldsMoreLateralAdvance() { // "Faster the more angle you run towards it" — ordering only, no // feel constants. var (pos30, _) = RunApproach(angleFromPerpendicularDeg: 30f); var (pos60, _) = RunApproach(angleFromPerpendicularDeg: 60f); float lat30 = LateralAdvance(pos30); float lat60 = LateralAdvance(pos60); Assert.True(lat60 > lat30, $"expected the more-angled approach to glide farther " + $"(lat60={lat60:F3} m vs lat30={lat30:F3} m)"); } [Fact] public void PerpendicularApproach_Stops() { // The user's paired retail observation: walking straight at the // face stops — there is no lateral component to preserve. var (finalPos, _) = RunApproach(angleFromPerpendicularDeg: 0f); float lateral = MathF.Abs(LateralAdvance(finalPos)); Assert.True(lateral < 0.15f, $"expected no lateral drift on a perpendicular approach, got " + $"{lateral:F3} m"); float dx = finalPos.X - StartX; float dy = finalPos.Y - StartY; float xyTravel = MathF.Sqrt(dx * dx + dy * dy); Assert.True(xyTravel < 1.2f, $"expected the too-steep face to stop the perpendicular " + $"approach at its base (~0.4 m away), got {xyTravel:F3} m of " + $"travel"); Assert.True(finalPos.Z < 1.0f, $"expected no climb on a perpendicular approach, got " + $"Z={finalPos.Z:F3}"); Assert.True(finalPos.Z > -0.05f, $"expected no sink on a perpendicular approach, got " + $"Z={finalPos.Z:F3}"); } /// /// Supplementary coverage, NOT the discriminator: a steep face rising /// from a cell BOUNDARY (whole neighboring cell raised). The primary /// terrain sample is cell-scoped, so this topology resolves through /// the cross-cell path and glides both pre- and post-fix; it pins the /// boundary behavior so the diagonal fix cannot regress it. /// [Fact] public void CellBoundaryFace_Angled45_AlsoGlides() { var engine = BuildBoundaryFaceEngine(); var body = NewGroundedBody(); var position = new Vector3(91f, 36f, 0f); uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, 91f, 36f); float d = DxyPerTick * 0.70710678f; for (int tick = 0; tick < 40; tick++) { var result = engine.ResolveWithTransition( currentPos: position, targetPos: new Vector3(position.X + d, position.Y + d, position.Z), cellId: cell, sphereRadius: 0.47f, sphereHeight: 1.20f, stepUpHeight: 0.60f, stepDownHeight: 1.50f, isOnGround: true, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x5000000Au); position = result.Position; cell = result.CellId; } Assert.True(position.Y - 36f > 0.5f, $"expected lateral advance along the boundary face, got " + $"{position.Y - 36f:F3} m"); Assert.True(position.Z < 1.0f, $"expected no climb up the boundary face, got Z={position.Z:F3}"); } private static float LateralAdvance(Vector3 finalPos) => (finalPos.X - StartX) * Lateral.X + (finalPos.Y - StartY) * Lateral.Y; private static (Vector3 FinalPos, int StuckTicks) RunApproach( float angleFromPerpendicularDeg) { var engine = BuildDiagonalFaceEngine(); var body = NewGroundedBody(); float rad = angleFromPerpendicularDeg * MathF.PI / 180f; Vector2 dir = MathF.Cos(rad) * IntoFace + MathF.Sin(rad) * Lateral; float dx = DxyPerTick * dir.X; float dy = DxyPerTick * dir.Y; var position = new Vector3(StartX, StartY, 0f); uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, StartX, StartY); int stuckTicks = 0; for (int tick = 0; tick < Ticks; tick++) { var result = engine.ResolveWithTransition( currentPos: position, targetPos: new Vector3(position.X + dx, position.Y + dy, position.Z), cellId: cell, sphereRadius: 0.47f, sphereHeight: 1.20f, stepUpHeight: 0.60f, stepDownHeight: 1.50f, isOnGround: true, body: body, moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, movingEntityId: 0x5000000Au); // The stuck-tick predicate, from positions: nonzero XY request, // zero XY delivered. if (result.Position.X == position.X && result.Position.Y == position.Y) stuckTicks++; position = result.Position; cell = result.CellId; } return (position, stuckTicks); } private static PhysicsBody NewGroundedBody() => new() { State = PhysicsStateFlags.Gravity, TransientState = TransientStateFlags.Active | TransientStateFlags.Contact | TransientStateFlags.OnWalkable, }; /// /// Only cell (3,3)'s TL post (x-index 3, y-index 4) is raised: its /// below-diagonal triangle stays flat at z=0 and its above-diagonal /// triangle carries the 32 m rise (N.z ≈ 0.469, too steep). x-major /// heights[x*9+y]; heightTable[i] = i meters. /// private static PhysicsEngine BuildDiagonalFaceEngine() { var heights = new byte[81]; heights[3 * 9 + 4] = 32; return BuildEngine(heights); } /// /// Posts 0..4 flat at 0, posts 5..8 at 32 m: cell cx=4 (x in [96,120]) /// carries the rise as a whole-cell face on the x=96 boundary /// (N = (-0.8, 0, 0.6)). /// private static PhysicsEngine BuildBoundaryFaceEngine() { var heights = new byte[81]; for (int x = 5; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = 32; return BuildEngine(heights); } private static PhysicsEngine BuildEngine(byte[] heights) { var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = i; var engine = new PhysicsEngine(); engine.AddLandblock( 0xA9B4FFFFu, new TerrainSurface(heights, heightTable), Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } }