acdream/tests/AcDream.Core.Tests/Physics/Issue345SteepSlopeGlideTests.cs
Erik ab89ebdf92
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
fix(physics): #345 — a grounded mover glides along a too-steep face; validate_walkable's return is scoped as retail's bytes scope it
Retail's OBJECTINFO::validate_walkable @0x0050d010 initializes its
return slot to OK (0x0050d025) and assigns ADJUSTED only inside the
below-plane guard, immediately after the push executes (0x0050d249).
The guard-fail path — grounded, OnWalkable, plane too steep — jumps
past the contact write, the push, and the assignment (0x0050d1b9 ->
0x0050d251): retail deliberately IGNORES the steep plane at primary
validation so the insert proceeds, the step-down phase fails on the
steep landing, and the edge family produces the per-tick lateral
glide. ACE flattened this into an unconditional return Adjusted
(ObjectInfo.cs:169) and we inherited it; our TransitionalInsert then
retried the byte-identical Adjusted forever — the user's
stop-instead-of-slide.

Evidence chain: the user's retail observation (the axiom), the live
cdb glide profile (edge_slide/cliff_slide 594 each in lockstep,
step_up 0), the D0 implementer's correct STOP (fixtures reproduced
the stuck fingerprint while faithfully executing the ACE-shaped
reading — refuting the reading, not the code), and the capstone
byte-decode both Opus reviewers re-derived independently, including
the stack-slot frame arithmetic and every ret site's eax.

The conformance fixture is the live topology: flat and steep terrain
triangles sharing ONE cell's diagonal (a cell-boundary face does NOT
reproduce the loop — the cell-scoped primary sample never validates a
neighbour's triangle — and is pinned as supplementary). Sabotage:
restoring the unconditional Adjusted reds the discriminator with the
exact stuck position (0.325 m lateral, 28/30 stuck ticks vs 2.602 m /
14/30 fixed; reviewer B's independent five-angle table is monotone
10-85 degrees). Stuck ticks are counted from positions so the
assertion survives the eventual probe strip.

In-game glide gate PASSED 2026-08-08: "Well it works, we are sliding.
I cant detect any speed change from retail."

Filed alongside: #347 + AD-70 (our glide alternates arm/move at half
retail's per-tick rate — retail redirects within the tick; next up by
user direction), AD-71 (the guard's mutable WalkableAllowance operand
vs retail's fixed is_valid_walkable global — now return-value-bearing),
and the reviewers' named residuals in the #345 closure entry
(placement-arm flip, other-cell coverage gap, EdgeSlide-less
projectiles, ACE's server-side shared misport predicting remote
drift-then-snap on steep terrain). The unported IsViewer arm of
validate_walkable is noted in the D0 doc.

Suite: clean-room complete solution 11,271 passed / 4 skipped / 0
failed; Core assembly re-run green after the review-driven test
hardening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:51 +02:00

271 lines
11 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 itself is a KNOWN half-rate residual vs retail's
// within-tick redirect (#347) — this assertion only rejects the
// dead loop. 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;
}
}