Per docs/research/2026-07-30-ts4-116-oracle-plan.md §1, §4 item 2 (the
decisive TS-4 confirming run). Retail's BSP layer has NO steepness test at
all (acclient_2013_pseudo_c.txt:323783-323821, 0x0053a793) — every airborne
hit, steep or shallow, falls through to the same unconditional
`SetCollide` + `Adjusted`. The L.4 slide-tangent shortcut (worldNormal.Z <
FloorZ -> project-and-Slid, with its own SetSlidingNormal write) is deleted
from both BSPQuery.cs's and FlatBspQuery.cs's Path 6 sphere0 branch.
Fixing FlatBspQuery.cs (the flat/indexed engine Slice I6/I7 made
production-authoritative) was necessary in this same commit: it carried an
exact structural duplicate of the shortcut, caught by
FlatBspQueryDifferentialTests.InstalledDat_LargeRandomizedSweep_HasZeroBitMismatch
(graph=Adjusted vs flat=Slid) once the graph side was fixed alone. Its
sphere1 branch is also brought in line with the #116 shape-1 fix landed
in db2889af (direct Collided + SetCollisionNormal instead of the deferred
SetCollide/shortcut treatment) — that parity gap existed since shape-1's
commit only touched BSPQuery.cs and the randomized differential sweep
didn't happen to exercise the narrow foot-clear/head-hit case until this
session's broader change surfaced it.
DECISIVE CONFIRMING RUN (Ts4SteepRoofWedgeCaptureTests, per the plan's own
required test-first order): added
FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor
— the same steep-roof drop as the existing pure-vertical fixture, but with
a small residual horizontal velocity (vx=-0.3 m/s), matching the realistic
live-play input (WASD, jump momentum) that validated the shortcut on
2026-04-30. With the shortcut removed, this variant converges cleanly to
the flat floor with zero freeze. The pure-vertical fixture, run
shortcut-removed, DOES still freeze — per the oracle plan's root-cause
trace (§1.2 Step E), this is the DEGENERATE case: AdjustOffset's crease
projection (Cross(ContactPlane.Normal, SlidingNormal)) is mathematically
orthogonal to a purely-Z gravity offset, crushing it to zero every tick
before TransitionalInsert can run again — present identically in the raw
decomp, ACE's port, and this port. Renamed and re-asserted as a PINNED
known-degenerate test
(FallOntoSteepSlope_PureVertical_FreezesAtDegenerateFixedPoint_RetailParity)
rather than treated as a bug. Filed as register row AD-56.
BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid pinned the
OLD shortcut's Slid-no-Collide behavior directly; renamed to
...ReturnsAdjustedAndSetsCollide and corrected to the retail-faithful
Adjusted/Collide=true outcome.
Register: TS-4 row retired (struck through, retirement note); AD-56 filed
for the pure-vertical degenerate case; the retire-next shortlist's TS-4
entry removed and renumbered.
Full AcDream.Core.Tests suite: 4060 passed / 2 skipped (D4 stays Skip-tagged
in this commit; its own un-skip is a separate, dependent test-only commit
for #116 shape-2), no regressions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
14 KiB
C#
321 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
using Xunit.Abstractions;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
/// <summary>
|
|
/// TS-4 RETIRED (Campaign P final physics slice, 2026-07-30;
|
|
/// docs/research/2026-07-30-ts4-116-oracle-plan.md §1, §4 item 2). The
|
|
/// Path-6 steep-poly slide-tangent shortcut (worldNormal.Z < FloorZ →
|
|
/// project-and-Slid, with its own SetSlidingNormal write) is deleted from
|
|
/// <see cref="BSPQuery"/>'s Path 6; both sphere0 and sphere1 steep hits now
|
|
/// fall through to the same unconditional <c>SetCollide</c> retail uses for
|
|
/// every hit (acclient_2013_pseudo_c.txt:323783-323821, 0x0053a793 — no
|
|
/// steepness test at the BSP layer at all).
|
|
///
|
|
/// <para>
|
|
/// This class builds a dat-free multi-frame replay from the existing
|
|
/// <see cref="BSPStepUpFixtures.SlopedUnwalkable"/> geometry (a 63.4° slope,
|
|
/// normal.Z ≈ 0.447 — below <c>PhysicsGlobals.FloorZ</c> ≈ 0.6642 but above
|
|
/// <c>PhysicsGlobals.LandingZ</c> ≈ 0.0871, exactly the band the retired
|
|
/// shortcut used to target) using the same
|
|
/// <c>PhysicsEngine.ResolveWithTransition</c> multi-frame replay idiom as
|
|
/// <c>Issue185OutdoorStairsSeamReplayTests</c>, gravity integrated between
|
|
/// resolves exactly as <c>PhysicsBody.UpdatePhysicsInternal</c> would, for
|
|
/// up to 3 simulated seconds (90 ticks at 30 Hz — retail's physics tick
|
|
/// rate, #32 L.5).
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The oracle plan's own root-cause trace (§1.2) found the freeze
|
|
/// mechanism one layer downstream of Path 6: Path 6's faithful
|
|
/// <c>SetCollide</c> doesn't reposition the sphere; the immediate retry
|
|
/// routes to Path 4 (<c>find_walkable</c>), which commits a real steep
|
|
/// <c>ContactPlane</c> via the permissive <c>LandingZ</c> gate; the NEXT
|
|
/// tick's <c>AdjustOffset</c> then projects the (by-then gravity-only)
|
|
/// offset through <c>Cross(ContactPlane.Normal, SlidingNormal)</c> — and
|
|
/// for a PURELY VERTICAL offset that cross product annihilates it exactly
|
|
/// (§1.2 Step E), tripping the abort-small-offset guard before
|
|
/// <c>TransitionalInsert</c> can run again. This is present identically in
|
|
/// the raw retail decomp, ACE's port, and this port (§1.2, §1.3) — it is
|
|
/// not a bug, it is what a truly zero-horizontal-velocity plumb drop onto a
|
|
/// steep surface does in every one of the three references. A live player
|
|
/// almost never produces this input (WASD, camera-relative movement, and
|
|
/// even float noise inject some horizontal component), which is why the
|
|
/// 2026-04-30 live-client debugger trace that originally validated the
|
|
/// shortcut never observed the freeze.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Two fixtures, two different fates, per the plan's own decisive test
|
|
/// (§4 item 2): <see cref="FallOntoSteepSlope_PureVertical_FreezesAtDegenerateFixedPoint_RetailParity"/>
|
|
/// is the DEGENERATE case (zero horizontal velocity) — PINNED as a known,
|
|
/// retail-matching freeze (see register row AD-56).
|
|
/// <see cref="FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor"/>
|
|
/// is the REALISTIC case (small residual horizontal velocity, matching the
|
|
/// original live repro's actual trajectory shape) — it converges cleanly to
|
|
/// the flat floor with no freeze, which is what made TS-4's removal safe to
|
|
/// land.
|
|
/// </para>
|
|
/// </summary>
|
|
public class Ts4SteepRoofWedgeCaptureTests
|
|
{
|
|
private readonly ITestOutputHelper _out;
|
|
public Ts4SteepRoofWedgeCaptureTests(ITestOutputHelper output) => _out = output;
|
|
|
|
private const uint CellId = 0xA9B40001u;
|
|
private const int TicksPerSecond = 30; // #32 L.5 retail physics tick rate
|
|
private const int MaxTicks = 3 * TicksPerSecond;
|
|
private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged
|
|
private const float WedgeEpsilon = 0.001f; // 1 mm
|
|
|
|
private static PhysicsEngine MakeSlopeEngine()
|
|
{
|
|
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
|
|
|
|
const uint LandblockId = 0xA9B4FFFFu;
|
|
const uint SyntheticGfxId = 0xDEADBEEFu;
|
|
|
|
var heights = new byte[81];
|
|
var heightTab = new float[256];
|
|
for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes
|
|
|
|
var engine = new PhysicsEngine();
|
|
engine.AddLandblock(
|
|
LandblockId,
|
|
new TerrainSurface(heights, heightTab),
|
|
System.Array.Empty<CellSurface>(),
|
|
System.Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f, worldOffsetY: 0f);
|
|
|
|
var cache = new PhysicsDataCache();
|
|
var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root };
|
|
var physics = new GfxObjPhysics
|
|
{
|
|
BSP = bspTree,
|
|
PhysicsPolygons = new System.Collections.Generic.Dictionary<ushort, DatReaderWriter.Types.Polygon>(),
|
|
Vertices = new DatReaderWriter.Types.VertexArray(),
|
|
Resolved = resolved,
|
|
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f },
|
|
};
|
|
cache.RegisterGfxObjForTest(SyntheticGfxId, physics);
|
|
engine.DataCache = cache;
|
|
|
|
engine.ShadowObjects.Register(
|
|
entityId: SyntheticGfxId,
|
|
gfxObjId: SyntheticGfxId,
|
|
worldPos: Vector3.Zero,
|
|
rotation: Quaternion.Identity,
|
|
radius: 15f,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: LandblockId,
|
|
collisionType: ShadowCollisionType.BSP,
|
|
scale: 1.0f);
|
|
|
|
return engine;
|
|
}
|
|
|
|
/// <summary>
|
|
/// PINNED known-degenerate case (Campaign P final physics slice,
|
|
/// 2026-07-30; docs/research/2026-07-30-ts4-116-oracle-plan.md §1.2-§1.3,
|
|
/// §4 item 2; register row AD-56). A body falling PERFECTLY PLUMB (zero
|
|
/// horizontal velocity) onto this 63.4° slope's mid-face lands, commits
|
|
/// a steep <c>ContactPlane</c> via Path 4's permissive <c>LandingZ</c>
|
|
/// gate, and then freezes at that exact position forever: the crease
|
|
/// projection inside <c>AdjustOffset</c>
|
|
/// (<c>Cross(ContactPlane.Normal, SlidingNormal)</c>) is mathematically
|
|
/// orthogonal to a purely-Z offset, crushing it to zero every tick and
|
|
/// tripping the abort-small-offset guard before <c>TransitionalInsert</c>
|
|
/// runs again. This is retail-faithful — present identically in the raw
|
|
/// decomp, ACE's port, and this port (§1.2) — and essentially
|
|
/// unreachable in live play, where WASD input, camera-relative movement,
|
|
/// and even float noise almost always inject some horizontal component
|
|
/// (see <see cref="FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor"/>
|
|
/// for the realistic case, which does NOT freeze). This test PINS the
|
|
/// freeze as accepted parity rather than treating it as a bug to fix.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FallOntoSteepSlope_PureVertical_FreezesAtDegenerateFixedPoint_RetailParity()
|
|
{
|
|
var engine = MakeSlopeEngine();
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
const float dt = 1f / TicksPerSecond;
|
|
const float gravity = -9.8f;
|
|
|
|
var body = new PhysicsBody
|
|
{
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
|
|
// Start well above the slope's mid-face (slope spans x in [0,1], z in
|
|
// [0,2] at that x-range), falling straight down with NO horizontal
|
|
// component — the degenerate input this pin documents.
|
|
Vector3 pos = new(0.5f, 0f, 3.0f);
|
|
float fallVelocityZ = 0f;
|
|
uint cell = CellId;
|
|
|
|
int frozenStreak = 0;
|
|
bool frozeAsExpected = false;
|
|
Vector3 frozenAtPosition = default;
|
|
|
|
for (int tick = 0; tick < MaxTicks; tick++)
|
|
{
|
|
fallVelocityZ += gravity * dt;
|
|
Vector3 target = pos + new Vector3(0f, 0f, fallVelocityZ * dt);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: pos,
|
|
targetPos: target,
|
|
cellId: cell,
|
|
sphereRadius: r,
|
|
sphereHeight: r * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: false,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000000u);
|
|
|
|
var newPos = result.Position;
|
|
float moved = Vector3.Distance(newPos, pos);
|
|
|
|
if (moved < WedgeEpsilon)
|
|
frozenStreak++;
|
|
else
|
|
frozenStreak = 0;
|
|
|
|
_out.WriteLine(
|
|
$"t{tick,3}: pos=({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) " +
|
|
$"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " +
|
|
$"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}");
|
|
|
|
pos = newPos;
|
|
cell = result.CellId;
|
|
body.Position = pos;
|
|
|
|
if (result.IsOnGround)
|
|
fallVelocityZ = 0f;
|
|
|
|
if (frozenStreak > WedgeTickThreshold)
|
|
{
|
|
frozeAsExpected = true;
|
|
frozenAtPosition = pos;
|
|
break;
|
|
}
|
|
}
|
|
|
|
Assert.True(frozeAsExpected,
|
|
$"Expected the degenerate pure-vertical drop to freeze for more than " +
|
|
$"{WedgeTickThreshold} consecutive ticks (retail-matching AdjustOffset " +
|
|
"crease-projection degeneracy, AD-56) within the {MaxTicks}-tick budget — " +
|
|
"it did not. Either the degenerate case no longer reproduces (re-evaluate " +
|
|
"this pin against the oracle plan) or an unrelated regression changed the " +
|
|
"slope-landing chain.");
|
|
Assert.True(frozenAtPosition.X > 0f,
|
|
"Expected the freeze to occur ON the steep slope (x>0), not at/after the " +
|
|
$"flat reference floor; got x={frozenAtPosition.X:F3}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P final physics slice, TS-4 decisive confirming run
|
|
/// (<c>docs/research/2026-07-30-ts4-116-oracle-plan.md</c> §1.2 Step E,
|
|
/// §1.3, §4 item 2). The pure-vertical fixture above is, per the oracle
|
|
/// plan, the DEGENERATE case: <c>AdjustOffset</c>'s crease projection
|
|
/// (<c>Cross(ContactPlane.Normal, SlidingNormal)</c> against a purely
|
|
/// gravity-only offset) is mathematically annihilated by construction
|
|
/// when the offset has zero horizontal component — <c>Dot(slideOffset,
|
|
/// offset) = 0</c> exactly, because <c>slideOffset.Z = 0</c> and the
|
|
/// offset is purely Z. Any lateral drift (WASD input, residual jump
|
|
/// momentum — present in the original 2026-04-30 live-client repro that
|
|
/// validated the shortcut, but NOT in the pure-vertical fixture above)
|
|
/// survives that same cross product and should let <c>AdjustOffset</c>
|
|
/// produce a small non-zero tangential offset each tick, moving the
|
|
/// sphere off the exact collision point, avoiding the abort-small-offset
|
|
/// short-circuit, and letting <c>TransitionalInsert</c> run again on
|
|
/// subsequent ticks.
|
|
/// </summary>
|
|
[Fact]
|
|
public void FallOntoSteepSlope_WithHorizontalVelocity_NeverFreezesForOverHalfASecond_AndReachesFloor()
|
|
{
|
|
var engine = MakeSlopeEngine();
|
|
float r = BSPStepUpFixtures.SphereRadius;
|
|
const float dt = 1f / TicksPerSecond;
|
|
const float gravity = -9.8f;
|
|
|
|
var body = new PhysicsBody
|
|
{
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
|
|
// Same drop point as the pure-vertical fixture, but with a small
|
|
// residual horizontal velocity toward the flat reference floor
|
|
// (x<0) — the realistic "jumped/walked onto the roof with some
|
|
// drift" case the L.4 shortcut's own validating trace exercised.
|
|
Vector3 pos = new(0.5f, 0f, 3.0f);
|
|
float fallVelocityZ = 0f;
|
|
const float horizontalVelocityX = -0.3f;
|
|
uint cell = CellId;
|
|
|
|
int frozenStreak = 0;
|
|
bool reachedFloor = false;
|
|
|
|
for (int tick = 0; tick < MaxTicks; tick++)
|
|
{
|
|
fallVelocityZ += gravity * dt;
|
|
Vector3 target = pos + new Vector3(horizontalVelocityX * dt, 0f, fallVelocityZ * dt);
|
|
|
|
var result = engine.ResolveWithTransition(
|
|
currentPos: pos,
|
|
targetPos: target,
|
|
cellId: cell,
|
|
sphereRadius: r,
|
|
sphereHeight: r * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: false,
|
|
body: body,
|
|
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000000u);
|
|
|
|
var newPos = result.Position;
|
|
float moved = Vector3.Distance(newPos, pos);
|
|
|
|
if (moved < WedgeEpsilon)
|
|
frozenStreak++;
|
|
else
|
|
frozenStreak = 0;
|
|
|
|
_out.WriteLine(
|
|
$"t{tick,3}: pos=({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) " +
|
|
$"moved={moved:F4} onGround={result.IsOnGround} onWalkable={result.OnWalkable} " +
|
|
$"contact={result.InContact} vz={fallVelocityZ:F2} frozen={frozenStreak}");
|
|
|
|
Assert.True(frozenStreak <= WedgeTickThreshold,
|
|
$"Body frozen for {frozenStreak} consecutive ticks (>{WedgeTickThreshold} == " +
|
|
$">0.5s) at tick {tick}, position ({newPos.X:F3},{newPos.Y:F3},{newPos.Z:F3}) — " +
|
|
"this is the 'stuck in falling animation on the roof' wedge shape.");
|
|
|
|
pos = newPos;
|
|
cell = result.CellId;
|
|
body.Position = pos;
|
|
|
|
if (result.IsOnGround)
|
|
fallVelocityZ = 0f;
|
|
|
|
if (pos.X < 0f && pos.Z <= r + 0.05f)
|
|
{
|
|
reachedFloor = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
Assert.True(reachedFloor,
|
|
$"Body never reached the flat reference floor within {MaxTicks} ticks " +
|
|
$"({MaxTicks / (float)TicksPerSecond:F1}s); final position " +
|
|
$"({pos.X:F3},{pos.Y:F3},{pos.Z:F3}).");
|
|
}
|
|
}
|