acdream/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
Erik b1af56eb19 fix(physics): L.4 — steep airborne hits slide-tangent (interim, deviates from retail)
Phase L.4 closes the "stuck in falling animation on a steep roof" bug
the user reported on 2026-04-30 ("I jump up, I land on it. It should not
even let me land, should just slide with a falling animation"). After
this commit the body no longer sticks to a steep roof when jumping
into it — it slides along the slope while keeping the falling animation.

Two pieces:

1. BSPQuery Path 6 steep-poly slide
   When an airborne sphere hits a polygon whose world normal Z is below
   FloorZ (≈ 0.6642, slope > ~49°), the previous flow was:
   Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed →
   body "lands" on the steep poly with Contact bit + falling animation.
   This left the player stuck mid-slope because OnWalkable was cleared
   but Contact stayed set.

   The new branch detects the steep normal in Path 6 BEFORE SetCollide
   is called. Instead of entering the landing path, it removes the
   into-wall component of the move (project onto the steep face), sets
   CollisionNormal + SlidingNormal, and returns Slid. Same shape as
   Path 5's step-up fallback and CylinderCollision. The resolver retries;
   the sphere is now outside the poly; FindCollisions returns OK;
   ValidateTransition commits the slid position. ContactPlane is never
   set, so the body stays airborne with falling animation.

2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop
   Re-enables the velocity-reflection bounce when the contact normal is
   upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base
   L.3a rule suppresses bounce on landing transitions to avoid micro-
   bounce on flat terrain; that suppression also stuck the player to
   too-steep roofs they shouldn't land on. This carve-out re-enables
   the reflection specifically for the steep upward case.

Also lands related L.2c precipice / edge-slide work that was in flight:

- TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep
  cliff route + steep-ContactPlane cliff route ordering, so that
  CliffSlide fires when the stored walkable polygon itself is too
  steep (Path 4 had previously accepted it as a "landing" via the
  permissive LandingZ threshold).
- CliffSlide reference-normal selection: prefer LastWalkable, fall back
  to LastKnownContactPlane only when walkable, else use world-up. This
  prevents the cross(steepN, steepN) = 0 degenerate case that left the
  cliff slide as a no-op when both current and last-known were steep.
- Phase 2 / step-down branch / edge-slide branch / cliff-slide
  diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF.
- Two new airborne-mover regression tests in BSPStepUpTests +
  PhysicsEngineTests covering wall-slide and edge tangent motion.

DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP

The Path 6 steep slide is NOT what retail does. Retail's flow on the
same hit is:

  Path 6 SetCollide (no steep check) → Path 4 find_walkable returns
  nothing for steep → Phase 3 reset path: restore_check_pos +
  kill_velocity → return COLLIDED → validate_transition reverts CheckPos
  to CurPos and forces OK.

Net retail behavior: position reverts to pre-failed-move (typically
just below the roof in the common jump-up case), velocity zeroed,
gravity rebuilds Z next frame, body falls back down naturally with
the falling animation. The "freeze" framing I used earlier was wrong;
in the typical case retail just bounces the body off and lets gravity
take over.

Strict retail behavior would match the user's intent better in the
common case AND avoid the bounce-energy-accumulation we saw with the
slide-tangent approach (V grew to ~50 m/s in continuous-contact frames).
However, retail's behavior degenerates in the edge case of an overhead
landing onto a steep slope (body would freeze mid-air above the roof).

This commit ships the slide-tangent fix as an interim "much better"
state per user verification on 2026-04-30. Follow-up work to match
retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to
ensure kill_velocity (matching OBJECTINFO::kill_velocity ->
CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test.

Refs:
  - acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide)
  - acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path)
  - acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert)
  - acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity)
  - acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce)

Tests: 833/833 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 13:22:07 +02:00

650 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in
/// <see cref="BSPQuery.FindCollisions"/>.
///
/// <para>
/// Tests are organised in three groups corresponding to the three commits:
/// </para>
/// <list type="bullet">
/// <item><b>Group A — Baselines</b>: behaviours that should pass both before
/// and after the implementation (no-hit returns OK, fixture geometry checks).</item>
/// <item><b>Group B — Phase L.2.1 (Path 5 step-up)</b>: tests that are RED
/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these
/// GREEN.</item>
/// <item><b>Group C — Phase L.2.2 (Path 6 SetCollide)</b>: tests that are RED
/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2
/// flips these GREEN.</item>
/// </list>
///
/// <para>
/// Retail references:
/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 /
/// ACE BSPTree.cs:192-196.
/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 /
/// ACE Transition.cs:746-777.
/// BSPTREE::find_collisions Path 6 / SPHEREPATH::set_collide —
/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219.
/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 /
/// ACE SpherePath.cs:279-286.
/// CTransition::transitional_insert Collide branch —
/// acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930.
/// </para>
/// </summary>
public class BSPStepUpTests
{
// =========================================================================
// Group A — Baselines (pass before AND after the implementation)
// =========================================================================
/// <summary>
/// No BSP geometry → FindCollisions returns OK with no state changes.
/// </summary>
[Fact]
public void A1_NullRoot_ReturnsOK()
{
var from = new Vector3(0f, 0f, BSPStepUpFixtures.SphereRadius);
var to = new Vector3(0.1f, 0f, BSPStepUpFixtures.SphereRadius);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
var localSphere = new DatReaderWriter.Types.Sphere
{
Origin = to,
Radius = BSPStepUpFixtures.SphereRadius,
};
var result = BSPQuery.FindCollisions(
null,
new Dictionary<ushort, ResolvedPolygon>(),
t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.Equal(TransitionState.OK, result);
}
/// <summary>
/// Grounded mover far from the wall → no collision → OK.
/// </summary>
[Fact]
public void A2_GroundedMover_NoWallNear_ReturnsOK()
{
var (root, resolved) = BSPStepUpFixtures.LowStep();
// Moving in -X, away from the wall at x=0.5.
var from = new Vector3(-1f, 0f, BSPStepUpFixtures.SphereRadius);
var to = new Vector3(-1.5f, 0f, BSPStepUpFixtures.SphereRadius);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.Equal(TransitionState.OK, result);
}
/// <summary>
/// Airborne mover well above the roof → no collision → OK.
/// </summary>
[Fact]
public void A3_AirborneMover_AboveRoof_ReturnsOK()
{
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
// Mover at z=6 (well above the roof at z=3) with tiny downward step.
float highZ = 6f;
var from = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius);
var to = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius - 0.01f);
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius };
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.Equal(TransitionState.OK, result);
}
/// <summary>
/// The slope fixture's polygon must have normal.Z below FloorZ (confirms
/// the fixture geometry is set up correctly as a non-walkable surface).
/// </summary>
[Fact]
public void A4_SlopedFixture_NormalBelowFloorZ()
{
var (_, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
var slope = resolved[BSPStepUpFixtures.SlopedUnwalkable_SlopeId];
Assert.True(slope.Plane.Normal.Z < PhysicsGlobals.FloorZ,
$"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be < FloorZ ({PhysicsGlobals.FloorZ:F4})");
Assert.True(slope.Plane.Normal.Z > 0f,
$"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be > 0 (upward-facing)");
}
/// <summary>
/// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable).
/// </summary>
[Fact]
public void A5_LowStepUpperFloor_NormalAboveFloorZ()
{
var (_, resolved) = BSPStepUpFixtures.LowStep();
var upper = resolved[BSPStepUpFixtures.LowStep_UpperFloorId];
Assert.True(upper.Plane.Normal.Z >= PhysicsGlobals.FloorZ,
$"Upper floor normal.Z ({upper.Plane.Normal.Z:F4}) must be >= FloorZ ({PhysicsGlobals.FloorZ:F4})");
}
/// <summary>
/// Roof polygon has normal.Z >= LandingZ (it can be landed on).
/// </summary>
[Fact]
public void A6_FlatRoofPolygon_NormalAboveLandingZ()
{
var (_, resolved) = BSPStepUpFixtures.FlatRoof();
var roof = resolved[BSPStepUpFixtures.FlatRoof_RoofId];
Assert.True(roof.Plane.Normal.Z >= PhysicsGlobals.LandingZ,
$"Roof normal.Z ({roof.Plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
}
// =========================================================================
// Group B — Phase L.2.1 (Path 5 step-up)
//
// RED before L.2.1, GREEN after.
// Each test documents the CURRENT wrong behaviour and EXPECTED correct one.
// =========================================================================
/// <summary>
/// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm):
/// should step up onto the upper floor, not slide sideways.
///
/// <para>
/// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall;
/// Z stays at floor level.
/// </para>
/// <para>
/// Expected after L.2.1: Path 5 calls StepUp → DoStepDown finds upper floor
/// → sphere lifts to z ≥ 0.25 + SphereRadius and X advances past the wall.
/// </para>
///
/// <para>Retail: BSPTREE::step_sphere_up / CTransition::step_up
/// acclient_2013_pseudo_c.txt:323849, 273099.</para>
/// </summary>
[Fact]
public void B1_GroundedMover_LowStep_StepsUp()
{
var (root, resolved) = BSPStepUpFixtures.LowStep();
const float stepUpHeight = 0.30f; // larger than step (0.25), so step-up succeeds
// CurPos (foot position) starts at z=0 (on the terrain / BSP floor at z=0).
// The sphere center is at CurPos + (0, 0, SphereRadius) = (x, 0, 0.2).
// lowPoint = sphere_center - (0,0,r) = (x, 0, 0) → on terrain → contact.
var from = new Vector3(0.1f, 0f, 0f);
// to.X = 0.6 → offset = (0.5, 0, 0), 3 sub-steps of 0.1667 each.
// Step 2: CurPos ≈ (0.433, 0, 0), sphere center x ≈ 0.433.
// Wall: dist = 0.5 - 0.433 = 0.067 < rad = 0.198 → HIT Path 5 ✓
var to = new Vector3(0.6f, 0f, 0f); // foot stays at z=0, crosses wall at x=0.5
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
// terrainZ=0f: terrain at z=0 keeps the step-down probe grounded between
// steps, preserving Contact/OnWalkable across the sub-step boundary.
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
bool ok = t.FindTransitionalPosition(engine);
// After step-up, the character's foot (CurPos.Z) must be at or above the
// upper floor (z=0.25). CurPos stores the foot origin; the sphere center is
// CurPos.Z + SphereRadius. The lower bound is the upper-floor Z minus a
// small epsilon to tolerate floating-point rounding in AdjustSphereToPlane.
float expectedMinZ = 0.25f - PhysicsGlobals.EPSILON * 10f;
Assert.True(t.SpherePath.CurPos.Z >= expectedMinZ,
$"Expected Z >= {expectedMinZ:F4} (stepped up to upper floor at z=0.25), " +
$"got CurPos.Z = {t.SpherePath.CurPos.Z:F4}. " +
"Path 5 must call StepUp (L.2.1) instead of wall-sliding.");
}
/// <summary>
/// Grounded mover walking into the too-tall wall (5 m) should NOT step up —
/// the wall is taller than StepUpHeight.
///
/// <para>
/// Expected: StepUp is called, DoStepDown finds no walkable surface within
/// 0.04 m (no upper floor exists), StepUpSlide applies → mover stays
/// left of the wall.
/// </para>
///
/// <para>Retail: SPHEREPATH::step_up_slide
/// ACE SpherePath.cs:309-316.</para>
/// </summary>
[Fact]
public void B2_GroundedMover_TallWall_BlockedOrSlides()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
const float stepUpHeight = 0.04f; // default — cannot scale 5 m wall
// Foot at z=0 (on terrain). Same reasoning as B1.
var from = new Vector3(0.1f, 0f, 0f);
var to = new Vector3(0.6f, 0f, 0f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
// terrainZ=0f: keep grounded between steps (same as B1).
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
t.FindTransitionalPosition(engine);
// The mover should NOT have crossed the wall at x=0.5.
float wallFace = 0.5f - BSPStepUpFixtures.SphereRadius;
Assert.True(t.SpherePath.CurPos.X <= wallFace + PhysicsGlobals.EPSILON * 20f,
$"Expected mover blocked before wall (x <= {wallFace:F3}), " +
$"got CurPos.X = {t.SpherePath.CurPos.X:F4}");
}
/// <summary>
/// Direct Path 5 invocation: Contact mover sphere just overlapping the low
/// wall should NOT return Slid after L.2.1.
///
/// <para>
/// Current: returns Slid (wall-slide).
/// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted.
/// </para>
/// </summary>
[Fact]
public void B3_Path5_DirectCall_ContactHitsLowWall_NotSlid()
{
var (root, resolved) = BSPStepUpFixtures.LowStep();
// Sphere center overlaps the wall (x=0.5) by half-radius.
float r = BSPStepUpFixtures.SphereRadius;
var checkPos = new Vector3(0.5f - r * 0.5f, 0f, r);
var currPos = new Vector3(0.1f, 0f, r);
var t = new Transition();
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
t.ObjectInfo.StepUpHeight = 0.30f;
t.ObjectInfo.StepDownHeight = 0.04f;
t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
t.CollisionInfo.LastKnownContactPlaneValid = true;
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
// Pass engine so Path 5 can call DoStepUp → DoStepDown (L.2.1).
// Without engine the fallback wall-slide would return Slid.
var engine = MakeTestEngine(root, resolved);
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f, Quaternion.Identity, engine);
// After L.2.1 this assertion flips from failing (Slid) to passing.
Assert.NotEqual(TransitionState.Slid, result);
}
// =========================================================================
// Group C — Phase L.2.2 (Path 6 SetCollide)
//
// RED before L.2.2, GREEN after.
// =========================================================================
/// <summary>
/// Airborne mover hitting the flat roof from above should set Collide flag
/// and return Adjusted (not Slid with wall-slide offset).
///
/// <para>
/// Current (wrong): Path 6 computes a wall-slide offset and returns Slid.
/// </para>
/// <para>
/// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets
/// WalkableAllowance = LandingZ, returns Adjusted.
/// </para>
///
/// <para>Retail: SPHEREPATH::set_collide
/// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219.</para>
/// </summary>
[Fact]
public void C1_Path6_AirborneMoverHitsRoof_SetsCollideFlagAndAdjusted()
{
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
// Sphere center just penetrating the roof polygon (z=3) from above.
float r = BSPStepUpFixtures.SphereRadius;
var checkPos = new Vector3(0f, 0f, 3f + r * 0.5f); // half-radius above roof
var currPos = new Vector3(0f, 0f, 3f + r + 0.1f); // clearly above
var t = new Transition();
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
t.ObjectInfo.State = ObjectInfoState.None; // airborne — no Contact
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
// After L.2.2: result = Adjusted, Collide = true, WalkableAllowance = LandingZ.
// Currently: result = Slid (wall-slide path).
Assert.Equal(TransitionState.Adjusted, result);
Assert.True(t.SpherePath.Collide,
"Expected SpherePath.Collide = true after Path 6 hit (L.2.2)");
Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance,
precision: 5);
}
/// <summary>
/// Full integration: airborne mover drops onto the 3 m flat roof.
///
/// <para>
/// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement,
/// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1.
/// </para>
/// <para>
/// Current: mover slides sideways off the roof (never lands).
/// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ.
/// </para>
/// </summary>
[Fact]
public void C2_AirborneMover_LandsOnFlatRoof_ContactPlaneSet()
{
var (root, resolved) = BSPStepUpFixtures.FlatRoof();
float roofZ = 3f;
float r = BSPStepUpFixtures.SphereRadius;
// CurPos = foot position. Sphere center = CurPos + (0,0,r).
// from: foot at z = roofZ - r + 0.3f → sphere center at roofZ + 0.3 = 3.3 (above roof)
// to: foot at z = roofZ - r - 0.05f → sphere center at roofZ - 0.05 = 2.95 (into roof by 0.05)
// Roof polygon at z=roofZ, normal=+Z: dist = sphere_center.z - roofZ.
// At to: dist = -0.05; |dist| = 0.05 < rad=0.198 → roof hit ✓
var from = new Vector3(0f, 0f, roofZ - r + 0.3f);
var to = new Vector3(0f, 0f, roofZ - r - 0.05f); // sphere bottom at z ≈ 2.95 (into roof)
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
// terrainZ=-50f: airborne mover — terrain must not interfere with roof landing.
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
t.FindTransitionalPosition(engine);
// After L.2.2: at least one of ContactPlane / LastKnownContactPlane is set.
bool planeSet = t.CollisionInfo.ContactPlaneValid
|| t.CollisionInfo.LastKnownContactPlaneValid;
Assert.True(planeSet,
"Expected a contact plane after landing on roof (L.2.2). " +
"Currently Path 6 wall-slides and never sets ContactPlane.");
if (planeSet)
{
var plane = t.CollisionInfo.ContactPlaneValid
? t.CollisionInfo.ContactPlane
: t.CollisionInfo.LastKnownContactPlane;
Assert.True(plane.Normal.Z >= PhysicsGlobals.LandingZ,
$"Contact plane normal.Z ({plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})");
}
}
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; FloorZ):
/// Path 6 should still set the Collide flag (it fires for any polygon hit,
/// walkable or not).
///
/// <para>Retail: set_collide fires unconditionally when sphere_intersects_poly
/// hits; the walkable check happens later in the Collide-flag handler.</para>
/// </summary>
[Fact]
public void C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide()
{
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
float r = BSPStepUpFixtures.SphereRadius;
// Approach the slope mid-face from above.
var checkPos = new Vector3(0.5f, 0f, 1.0f + r * 0.5f);
var currPos = new Vector3(0.5f, 0f, 1.0f + r + 0.1f);
var t = new Transition();
t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r);
t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u);
t.ObjectInfo.State = ObjectInfoState.None; // airborne
var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r };
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
// After L.2.2: Collide flag set, Adjusted returned.
// Currently: Slid (wall-slide).
Assert.Equal(TransitionState.Adjusted, result);
Assert.True(t.SpherePath.Collide,
"Expected Collide flag set when airborne sphere hits slope (L.2.2)");
}
// =========================================================================
// Group D — Phase L.2.3 regression tests
//
// Bugs caught by live testing 2026-04-29:
// D1 — walking into a too-tall wall must NOT clear ContactPlane (animation
// flickers to "falling" when contact is lost mid-step against a wall).
// D2 — Path 5 step-up must NOT recurse infinitely against a tall wall
// (retail guards step_sphere_up with `if (sp.step_up == 0)` per
// acclient_2013_pseudo_c.txt:272954). Without the guard, DoStepUp
// invokes DoStepDown which TransitionalInsert(5)'s into FindObjCollisions
// which hits the same wall AGAIN → recursive DoStepUp.
// =========================================================================
/// <summary>
/// L.2.3c regression: a grounded mover walking into a too-tall wall must
/// retain its ground contact across the failed step-up. Before the fix,
/// <c>DoStepUp</c> cleared <see cref="CollisionInfo.ContactPlaneValid"/>
/// unconditionally; on failure, RestoreCheckPos restored the position but
/// the contact plane stayed cleared, causing OnWalkable to drop and the
/// animation system to interpret the stuck-against-wall state as "airborne".
/// </summary>
[Fact]
public void D1_GroundedMover_TooTallWall_PreservesContactPlane()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
// Foot at z=0, walking into the wall.
var from = new Vector3(0.1f, 0f, 0f);
var to = new Vector3(0.6f, 0f, 0f);
// StepUpHeight 0.04m — too small to climb the 5m wall.
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
t.FindTransitionalPosition(engine);
// After failed step-up + slide, the mover should still be considered
// grounded — either via the live contact plane, the last-known one,
// or the OnWalkable flag preserved by terrain re-detection.
bool stillGrounded = t.CollisionInfo.ContactPlaneValid
|| t.CollisionInfo.LastKnownContactPlaneValid
|| t.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
Assert.True(stillGrounded,
"Expected mover to still be grounded after walking into a too-tall " +
"wall (failed step-up should preserve LastKnownContactPlane).");
}
/// <summary>
/// L.2.3b regression: Path 5 dispatch must be guarded against re-entry while
/// a step-up is already in progress. Test runs <c>FindTransitionalPosition</c>
/// with a tight time budget and verifies it terminates cleanly. Without the
/// guard the recursive DoStepUp churns the contact plane until numAttempts
/// runs out — finishing in an inconsistent state.
/// </summary>
[Fact]
public void D2_GroundedMover_TallWall_DoesNotRecurseInfinitely()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var from = new Vector3(0.1f, 0f, 0f);
var to = new Vector3(0.6f, 0f, 0f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight: 0.04f);
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
var sw = System.Diagnostics.Stopwatch.StartNew();
t.FindTransitionalPosition(engine);
sw.Stop();
// Bounded execution: even with recursion, this is a 4-step movement.
// 100ms is generous; without the guard, recursion adds noticeable cost.
Assert.True(sw.ElapsedMilliseconds < 100,
$"Step-up against tall wall took {sw.ElapsedMilliseconds}ms — " +
"indicates Path 5 recursing through DoStepUp without guard.");
}
/// <summary>
/// L.2c regression: an airborne mover jumping/falling into a vertical wall
/// must keep its vertical displacement. With no live or last-known contact
/// plane, SlideSphere must remove only the component into the wall; inventing
/// a flat UnitZ plane projects the displacement onto the wall/floor crease
/// and leaves the character stuck in falling animation against the wall.
/// </summary>
[Fact]
public void D3_AirborneMover_TallWall_PreservesVerticalMotion()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var from = new Vector3(0.1f, 0f, 2.0f);
var to = new Vector3(0.6f, 0f, 1.5f);
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
t.FindTransitionalPosition(engine);
Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f,
$"Expected airborne wall-slide to preserve downward motion; " +
$"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}");
Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f,
$"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}");
}
/// <summary>
/// L.2c regression: if an airborne wall collision happens in a one-substep
/// frame, the collision normal has to survive into the next frame. Retail
/// does this with transient_state bit 2 + InitSlidingNormal. Without that,
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
/// </summary>
[Fact]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
var body = new PhysicsBody
{
Position = new Vector3(0.25f, 0f, 2.0f),
TransientState = TransientStateFlags.Active,
};
var frame1 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: new Vector3(0.36f, 0f, 1.92f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
body.Position = frame1.Position;
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"First airborne wall hit should cache SlidingNormal for the next frame.");
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
var frame2 = engine.ResolveWithTransition(
currentPos: body.Position,
targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f),
cellId: 0xA9B40001u,
sphereRadius: BSPStepUpFixtures.SphereRadius,
sphereHeight: 0f,
stepUpHeight: 0.04f,
stepDownHeight: 0.04f,
isOnGround: false,
body: body);
Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f,
$"Expected cached wall-slide normal to allow falling on frame 2; " +
$"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}");
Assert.InRange(frame2.Position.X, 0.24f, 0.31f);
}
// =========================================================================
// Helpers
// =========================================================================
/// <summary>
/// Build a <see cref="PhysicsEngine"/> that serves one synthetic BSP object.
/// <paramref name="terrainZ"/> sets every terrain sample to the given height.
/// Use 0f for grounded tests (terrain flush with the BSP floor at z=0, so the
/// step-down probe finds ground and keeps Contact/OnWalkable set between steps).
/// Use -50f for tests where terrain must never interfere (airborne / roof landing).
/// </summary>
private static PhysicsEngine MakeTestEngine(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3? objectPosition = null,
float terrainZ = 0f)
{
const uint LandblockId = 0xA9B4FFFFu;
const uint SyntheticGfxId = 0xDEADBEEFu;
var heights = new byte[81]; // all zero → uses index 0 from heightTable
var heightTab = new float[256];
for (int i = 0; i < 256; i++) heightTab[i] = terrainZ;
var engine = new PhysicsEngine();
engine.AddLandblock(
LandblockId,
new TerrainSurface(heights, heightTab),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
// Register the BSP physics into the data cache.
var cache = new PhysicsDataCache();
var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root };
var physics = new GfxObjPhysics
{
BSP = bspTree,
PhysicsPolygons = new 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;
// Register the object in the shadow registry so FindObjCollisions picks it up.
Vector3 pos = objectPosition ?? Vector3.Zero;
engine.ShadowObjects.Register(
entityId: SyntheticGfxId,
gfxObjId: SyntheticGfxId,
worldPos: pos,
rotation: Quaternion.Identity,
radius: 15f,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: LandblockId,
collisionType: ShadowCollisionType.BSP,
scale: 1.0f);
return engine;
}
}