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
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>
271 lines
11 KiB
C#
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;
|
|
}
|
|
}
|