Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The round-2 cdb capture is decisive: during a live retail glide, edge_slide fired ~1.5 times per find_transitional_position — the arm/move alternation's exact signature (3 entries on the arming tick, 0 on the moving tick) — with cliff_slide in lockstep, step_down at 2.5x, step_up 0, and every stack sample on our identical call path. cliff_slide's bytes match our port and ACE's (compare constant at 0x794610 verified 0.0), and the user could not distinguish the two clients side by side. The "retail redirects within the tick" premise misread round-1's set_sliding_normal cadence (per-event, not per-tick, so its 1:1 ratio with edge never discriminated anything). The alternation-tolerant assertion in Issue345SteepSlopeGlideTests is therefore the CORRECT retail-shape pin from both sides; its comment now cites the capture instead of calling the shape a residual. The #269 note is honest the other way: the hope that a within-tick port would explain that feel residual is withdrawn with the premise. The temporary Scratch347 diagnostic is deleted. Capture evidence: 345-glide-stacks.cdb.log (repo root, untracked, cited from the contract's RESOLUTION section). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
12 KiB
C#
273 lines
12 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// #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
|
|
/// <c>docs/research/2026-08-08-345-d0-branch-pin.md</c> establish the
|
|
/// mechanism: retail's <c>validate_walkable</c> 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
|
|
/// <c>var_1c = 3</c> 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.
|
|
///
|
|
/// <para>
|
|
/// <b>Discriminating fixture (the live #345 topology):</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static PhysicsEngine BuildDiagonalFaceEngine()
|
|
{
|
|
var heights = new byte[81];
|
|
heights[3 * 9 + 4] = 32;
|
|
return BuildEngine(heights);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)).
|
|
/// </summary>
|
|
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<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
return engine;
|
|
}
|
|
}
|