fix(physics): restore retail path-6 collision response

This commit is contained in:
Erik 2026-07-31 13:44:55 +02:00
parent acec33eca8
commit 75b6f6b6c9
8 changed files with 523 additions and 223 deletions

View file

@ -396,18 +396,12 @@ public class BSPStepUpTests
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; FloorZ):
/// Path 6 returns <see cref="TransitionState.Slid"/> and does NOT set
/// the Collide flag — the steep-normal slide-tangent branch (L.4,
/// commit b1af56e, 2026-04-30) intercepts the hit before SetCollide is
/// called and projects the move along the steep face instead, keeping the
/// body airborne with the falling animation.
///
/// <para>This is a documented intentional deviation from retail (retail calls
/// set_collide unconditionally; our interim port uses slide-tangent while
/// the retail step_up_slide / cliff_slide chain port is completed).</para>
/// retail Path 6 still calls SetCollide, installs LandingZ, and returns
/// Adjusted. Polygon steepness is handled by the outer transition chain,
/// not by a BSP-layer tangent shortcut.
/// </summary>
[Fact]
public void C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid()
public void C3_Path6_AirborneMoverHitsSteepSlope_DefersThroughSetCollide()
{
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
@ -427,13 +421,11 @@ public class BSPStepUpTests
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
// L.4 slide-tangent (b1af56e, 2026-04-30): steep polygon hit by
// airborne sphere returns Slid (not Adjusted) and does NOT set
// the Collide flag — the into-wall displacement is removed and
// CollisionNormal/SlidingNormal are set instead.
Assert.Equal(TransitionState.Slid, result);
Assert.False(t.SpherePath.Collide,
"Collide must NOT be set when the L.4 steep-slope slide-tangent fires");
Assert.Equal(TransitionState.Adjusted, result);
Assert.True(t.SpherePath.Collide);
Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance);
Assert.False(t.CollisionInfo.CollisionNormalValid);
Assert.False(t.CollisionInfo.SlidingNormalValid);
}
// =========================================================================
@ -594,17 +586,7 @@ public class BSPStepUpTests
/// every frame replays the same hard stop and the character hangs in falling
/// animation until another correction breaks the loop.
/// </summary>
[Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " +
"on the first airborne wall frame; this pin expects an L.2c hard stop " +
"at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " +
"(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " +
"SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " +
"expectation is the SUSPECT half — but whether retail's first " +
"airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " +
"(collide_with_environment dispatch / no last-known plane) needs a " +
"cdb trace of an airborne wall hit before flipping the assertion. The " +
"#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " +
"offset is a real slide, not degenerate. See docs/ISSUES.md #116.")]
[Fact]
public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames()
{
var (root, resolved) = BSPStepUpFixtures.TallWall();
@ -630,6 +612,8 @@ public class BSPStepUpTests
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
"First airborne wall hit should cache SlidingNormal for the next frame.");
// Path 6's primary-sphere SetCollide hard-stops this first frame; the
// persisted normal then permits the downward tangent on frame two.
Assert.Equal(2.0f, frame1.Position.Z, precision: 3);
var frame2 = engine.ResolveWithTransition(

View file

@ -231,16 +231,18 @@ public sealed class RetailEdgeResponseOrderingTests
}
[Fact]
public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge()
public void MultiFrameSteepRoof_PureVertical_GraphAndFlatSlideDownhillWithoutWedge()
{
TraceRun graph = RunSteepRoofTrace(preparedFlat: false);
TraceRun flat = RunSteepRoofTrace(preparedFlat: true);
TraceRun graph = RunSteepRoofTrace(preparedFlat: false, Vector2.Zero);
TraceRun flat = RunSteepRoofTrace(preparedFlat: true, Vector2.Zero);
AssertTraceParity(graph, flat);
Assert.Contains(graph.Frames, frame =>
frame.Result.Position.X < 0f
&& frame.Result.Position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f);
Assert.Contains(graph.Frames, frame => frame.Result.InContact);
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
Assert.True(graph.Frames[^1].Result.Position.X
< graph.Frames[0].Result.Position.X - 0.20f,
$"The vertical trace did not descend the roof: " +
$"{graph.Frames[0].Result.Position} -> {graph.Frames[^1].Result.Position}.");
Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
@ -272,6 +274,69 @@ public sealed class RetailEdgeResponseOrderingTests
}
}
[Theory]
[InlineData(-0.30f, 0f, "downhill")]
[InlineData( 0.30f, 0f, "uphill")]
[InlineData( 0f, 0.30f, "tangential")]
public void MultiFrameSteepRoof_DirectionalMotion_RemainsExactAndPreservesRetailResponse(
float velocityX,
float velocityY,
string direction)
{
Vector2 horizontalVelocity = new(velocityX, velocityY);
TraceRun graph = RunSteepRoofTrace(preparedFlat: false, horizontalVelocity);
TraceRun flat = RunSteepRoofTrace(preparedFlat: true, horizontalVelocity);
AssertTraceParity(graph, flat);
Assert.Contains(graph.Frames, frame => frame.Result.InContact);
AssertNoLongFrozenStreak(graph.Frames, maximumTicks: 15);
Vector3 first = graph.Frames[0].Result.Position;
Vector3 last = graph.Frames[^1].Result.Position;
Vector2 progress = new(last.X - first.X, last.Y - first.Y);
if (direction == "uphill")
{
int contactFrame = graph.Frames.FindIndex(frame => frame.Result.InContact);
Assert.True(contactFrame >= 0);
float peakAfterContact = graph.Frames
.GetRange(contactFrame, graph.Frames.Count - contactFrame)
.Max(frame => frame.Result.Position.Z);
Assert.True(peakAfterContact <= graph.Frames[contactFrame].Result.Position.Z + 0.001f,
$"The uphill trace launched/bounced from the roof: " +
$"contactZ={graph.Frames[contactFrame].Result.Position.Z}, peak={peakAfterContact}.");
}
else
{
Assert.True(Vector2.Dot(progress, Vector2.Normalize(horizontalVelocity)) > 0.10f,
$"The {direction} trace lost requested progress: {first} -> {last}.");
}
Plane slope = BSPStepUpFixtures.SlopedUnwalkable().Resolved[
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane;
float radius = BSPStepUpFixtures.SphereRadius;
for (int i = 0; i < graph.Frames.Count; i++)
{
Vector3 position = graph.Frames[i].Result.Position;
AssertFinite(position, $"steep-roof {direction} frame {i}");
if (i > 0)
{
float distance = Vector3.Distance(
graph.Frames[i - 1].Result.Position,
position);
Assert.InRange(distance, 0f, 1.1f);
}
if (position.X is >= 0f and <= 1f && MathF.Abs(position.Y) <= 1f)
{
Vector3 footCenter = position + new Vector3(0f, 0f, radius);
float signedDistance = Vector3.Dot(slope.Normal, footCenter) + slope.D;
Assert.True(signedDistance >= radius - 0.015f,
$"Steep-roof {direction} penetration at frame {i}: " +
$"distance={signedDistance}, radius={radius}, position={position}.");
}
}
}
[Fact]
public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge()
{
@ -325,7 +390,7 @@ public sealed class RetailEdgeResponseOrderingTests
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MultiFrameGroundedFloorWallSlide_FinalPlacementIsMandatoryAndGraphFlatExact(
public void MultiFrameGroundedFloorWallSlide_InwardTangentialMotionIsGraphFlatExact(
bool twoSpheres)
{
WallMaintenanceTrace graph = RunGroundedFloorWallSlide(
@ -391,14 +456,22 @@ public sealed class RetailEdgeResponseOrderingTests
new(-2f, 2f, 0f),
];
private static TraceRun RunSteepRoofTrace(bool preparedFlat)
private static TraceRun RunSteepRoofTrace(
bool preparedFlat,
Vector2 horizontalVelocity)
{
var fixture = BSPStepUpFixtures.SlopedUnwalkable();
PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u);
float radius = BSPStepUpFixtures.SphereRadius;
const float dt = 1f / 30f;
const float gravity = -9.8f;
var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
var body = new PhysicsBody
{
Position = new Vector3(0.5f, 0f, 3f),
Orientation = Quaternion.Identity,
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.Active,
};
Vector3 position = new(0.5f, 0f, 3f);
float velocityZ = 0f;
var trace = new List<TraceFrame>(90);
@ -406,15 +479,19 @@ public sealed class RetailEdgeResponseOrderingTests
for (int tick = 0; tick < 90; tick++)
{
velocityZ += gravity * dt;
body.Velocity = new Vector3(
horizontalVelocity.X,
horizontalVelocity.Y,
velocityZ);
ResolveResult result = engine.ResolveWithTransition(
position,
position + new Vector3(0f, 0f, velocityZ * dt),
position + body.Velocity * dt,
Cell,
radius,
radius * 2f,
stepUpHeight: 0.30f,
stepDownHeight: 0.04f,
isOnGround: false,
isOnGround: body.OnWalkable,
body,
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
@ -422,7 +499,13 @@ public sealed class RetailEdgeResponseOrderingTests
position = result.Position;
body.Position = position;
if (result.IsOnGround)
{
velocityZ = 0f;
body.Velocity = new Vector3(
horizontalVelocity.X,
horizontalVelocity.Y,
0f);
}
ApplyContactResult(body, result);
trace.Add(CaptureFrame(result, body));

View file

@ -0,0 +1,321 @@
using System.Collections.Generic;
using System.Numerics;
using System.Text;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Exact site tests for retail <c>BSPTREE::find_collisions</c> Path 6
/// (<c>0x0053A793..0x0053A7DC</c>). Primary-sphere hits defer through
/// SetCollide; secondary-only hits hard-stop. Neither BSP branch owns the
/// persistent sliding normal.
/// </summary>
public sealed class Ts4Path6ConformanceTests
{
private const uint Cell = 0xA9B40001u;
private const float Radius = BSPStepUpFixtures.SphereRadius;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void PrimarySteepHit_GraphAndFlat_SetCollideWithExactState(
bool seedSlidingNormal)
{
var fixture = Normalize(BSPStepUpFixtures.SlopedUnwalkable());
FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp(
fixture.Root,
fixture.Resolved);
Vector3 currentBody = new(0.5f, 0f, 1.1f);
Vector3 targetBody = new(0.5f, 0f, 0.9f);
Vector3 targetCenter = targetBody + new Vector3(0f, 0f, Radius);
Vector3 expectedNormal = fixture.Resolved[
BSPStepUpFixtures.SlopedUnwalkable_SlopeId].Plane.Normal;
Vector3 seededSliding = Vector3.UnitY;
SiteOutcome graph = Run(
fixture.Root,
fixture.Resolved,
flat: null,
currentBody,
targetBody,
new Sphere { Origin = targetCenter, Radius = Radius },
head: null,
seedSlidingNormal,
seededSliding);
SiteOutcome prepared = Run(
root: null,
fixture.Resolved,
flat,
currentBody,
targetBody,
new Sphere { Origin = targetCenter, Radius = Radius },
head: null,
seedSlidingNormal,
seededSliding);
Assert.Equal(graph.Bits, prepared.Bits);
Assert.Equal(TransitionState.Adjusted, graph.State);
Assert.True(graph.Collide);
AssertVectorBits(targetBody, graph.CheckPos);
Assert.Equal(Cell, graph.CheckCellId);
AssertVectorBits(targetBody, graph.BackupCheckPos);
Assert.Equal(Cell, graph.BackupCheckCellId);
AssertVectorBits(expectedNormal, graph.StepUpNormal);
AssertFloatBits(1f, graph.WalkInterp);
AssertFloatBits(PhysicsGlobals.LandingZ, graph.WalkableAllowance);
Assert.False(graph.CollisionNormalValid);
Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid);
AssertVectorBits(
seedSlidingNormal ? seededSliding : Vector3.Zero,
graph.SlidingNormal);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SecondaryOnlyHit_GraphAndFlat_HardStopsWithoutSetCollide(
bool seedSlidingNormal)
{
(PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved) =
BuildRaisedWall();
FlatPhysicsBsp flat = FlatCollisionAssetBuilder.FlattenPhysicsBsp(
root,
resolved);
Vector3 currentBody = new(0.1f, 0f, 0f);
Vector3 targetBody = new(0.35f, 0f, 0f);
var foot = new Sphere
{
Origin = targetBody + new Vector3(0f, 0f, Radius),
Radius = Radius,
};
var head = new Sphere
{
Origin = targetBody + new Vector3(0f, 0f, 0.8f),
Radius = Radius,
};
Vector3 seededSliding = Vector3.UnitY;
SiteOutcome graph = Run(
root,
resolved,
flat: null,
currentBody,
targetBody,
foot,
head,
seedSlidingNormal,
seededSliding);
SiteOutcome prepared = Run(
root: null,
resolved,
flat,
currentBody,
targetBody,
foot,
head,
seedSlidingNormal,
seededSliding);
Assert.Equal(graph.Bits, prepared.Bits);
Assert.Equal(TransitionState.Collided, graph.State);
Assert.False(graph.Collide);
AssertVectorBits(targetBody, graph.CheckPos);
Assert.Equal(Cell, graph.CheckCellId);
AssertVectorBits(new Vector3(91f, 92f, 93f), graph.BackupCheckPos);
Assert.Equal(0xA9B40077u, graph.BackupCheckCellId);
AssertVectorBits(Vector3.Zero, graph.StepUpNormal);
AssertFloatBits(0.625f, graph.WalkInterp);
AssertFloatBits(0.8125f, graph.WalkableAllowance);
Assert.True(graph.CollisionNormalValid);
AssertFloatBits(-1f, graph.CollisionNormal.X);
Assert.Equal(0f, graph.CollisionNormal.Y);
Assert.Equal(0f, graph.CollisionNormal.Z);
Assert.Equal(seedSlidingNormal, graph.SlidingNormalValid);
AssertVectorBits(
seedSlidingNormal ? seededSliding : Vector3.Zero,
graph.SlidingNormal);
}
private static SiteOutcome Run(
PhysicsBSPNode? root,
Dictionary<ushort, ResolvedPolygon> resolved,
FlatPhysicsBsp? flat,
Vector3 currentBody,
Vector3 targetBody,
Sphere foot,
Sphere? head,
bool seedSlidingNormal,
Vector3 seededSliding)
{
var transition = new Transition();
transition.SpherePath.InitPath(
currentBody,
targetBody,
Cell,
Radius,
sphereHeight: head is null ? 0f : 1f);
transition.SpherePath.SetCheckPos(targetBody, Cell);
transition.SpherePath.BackupCheckPos = new Vector3(91f, 92f, 93f);
transition.SpherePath.BackupCheckCellId = 0xA9B40077u;
transition.SpherePath.WalkInterp = 0.625f;
transition.SpherePath.WalkableAllowance = 0.8125f;
if (seedSlidingNormal)
transition.CollisionInfo.SetSlidingNormal(seededSliding);
TransitionState state = flat is null
? BSPQuery.FindCollisions(
root,
resolved,
transition,
foot,
head,
currentBody,
Vector3.UnitZ,
1f)
: FlatBspQuery.FindCollisions(
flat,
transition,
foot,
head,
currentBody,
Vector3.UnitZ,
1f);
SpherePath path = transition.SpherePath;
CollisionInfo collision = transition.CollisionInfo;
return new SiteOutcome(
state,
path.Collide,
path.CheckPos,
path.CheckCellId,
path.BackupCheckPos,
path.BackupCheckCellId,
path.StepUpNormal,
path.WalkInterp,
path.WalkableAllowance,
collision.CollisionNormalValid,
collision.CollisionNormal,
collision.SlidingNormalValid,
collision.SlidingNormal,
Signature(state, path, collision));
}
private static (
PhysicsBSPNode Root,
Dictionary<ushort, ResolvedPolygon> Resolved) BuildRaisedWall()
{
Vector3[] vertices =
[
new(0.5f, -1f, 0.55f),
new(0.5f, -1f, 2.5f),
new(0.5f, 1f, 2.5f),
new(0.5f, 1f, 0.55f),
];
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = new Vector3(0.5f, 0f, 1.5f),
Radius = 4f,
},
};
root.Polygons.Add(1);
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[1] = new ResolvedPolygon
{
Id = 1,
Vertices = vertices,
Plane = new Plane(-Vector3.UnitX, 0.5f),
NumPoints = vertices.Length,
SidesType = CullMode.None,
},
};
return (root, resolved);
}
private static (
PhysicsBSPNode Root,
Dictionary<ushort, ResolvedPolygon> Resolved) Normalize(
(PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved) fixture)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(fixture.Resolved.Count);
foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved)
{
resolved.Add(id, new ResolvedPolygon
{
Id = id,
Vertices = polygon.Vertices,
Plane = polygon.Plane,
NumPoints = polygon.NumPoints,
SidesType = polygon.SidesType,
});
}
return (fixture.Root, resolved);
}
private static string Signature(
TransitionState state,
SpherePath path,
CollisionInfo collision)
{
var bits = new StringBuilder(256);
bits.Append((int)state).Append('|').Append(path.Collide ? 1 : 0).Append('|');
Append(bits, path.CheckPos);
bits.Append(path.CheckCellId.ToString("X8")).Append('|');
Append(bits, path.BackupCheckPos);
bits.Append(path.BackupCheckCellId.ToString("X8")).Append('|');
Append(bits, path.StepUpNormal);
Append(bits, path.WalkInterp);
Append(bits, path.WalkableAllowance);
bits.Append(collision.CollisionNormalValid ? 1 : 0).Append('|');
Append(bits, collision.CollisionNormal);
bits.Append(collision.SlidingNormalValid ? 1 : 0).Append('|');
Append(bits, collision.SlidingNormal);
return bits.ToString();
}
private static void Append(StringBuilder target, float value) =>
target.Append(BitConverter.SingleToUInt32Bits(value).ToString("X8")).Append('|');
private static void Append(StringBuilder target, Vector3 value)
{
Append(target, value.X);
Append(target, value.Y);
Append(target, value.Z);
}
private static void AssertFloatBits(float expected, float actual) =>
Assert.Equal(
BitConverter.SingleToUInt32Bits(expected),
BitConverter.SingleToUInt32Bits(actual));
private static void AssertVectorBits(Vector3 expected, Vector3 actual)
{
AssertFloatBits(expected.X, actual.X);
AssertFloatBits(expected.Y, actual.Y);
AssertFloatBits(expected.Z, actual.Z);
}
private sealed record SiteOutcome(
TransitionState State,
bool Collide,
Vector3 CheckPos,
uint CheckCellId,
Vector3 BackupCheckPos,
uint BackupCheckCellId,
Vector3 StepUpNormal,
float WalkInterp,
float WalkableAllowance,
bool CollisionNormalValid,
Vector3 CollisionNormal,
bool SlidingNormalValid,
Vector3 SlidingNormal,
string Bits);
}

View file

@ -1,4 +1,3 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
@ -7,51 +6,13 @@ using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Campaign P Slice P2, TS-4 (Section 6 Step 3): the 2026-04-30 "L.4" fixture
/// capture required before the Path-6 steep-poly slide-tangent shortcut may be
/// removed (<c>docs/research/2026-07-30-response-layer-edge-family-pseudocode.md</c>
/// §4, §6 Step 3). The original repro was a live-client jump onto a steep
/// roof that got the body "stuck in falling animation" for many frames; no
/// captured fixture from that live session survives in the repo (checked
/// <c>docs/research/2026-04-30-*</c> and the L.4 commit `b1af56e`), so this
/// test 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, i.e. exactly the band the
/// L.4 commit's own steep-poly shortcut targets) using the same
/// <c>PhysicsEngine.ResolveWithTransition</c> multi-frame replay idiom as
/// <c>Issue185OutdoorStairsSeamReplayTests</c>.
///
/// <para>
/// A body falls from directly above the slope's mid-face, integrating
/// gravity 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). "Wedged" is defined precisely, matching the original
/// bug report ("stuck in falling animation on the roof" for many consecutive
/// frames): the body's position stops changing (within 1 mm) for more than
/// 15 consecutive ticks (0.5 s) while never reaching the flat reference
/// floor at x&lt;0, z=0. A healthy resolution reaches the flat floor (Z ≈
/// <see cref="BSPStepUpFixtures.SphereRadius"/>) well before the 90-tick
/// budget expires, whether it does so by retail's own COLLIDED-then-fall
/// bounce (this file's own git history documents that as retail's actual
/// behavior for a clean Path-6 steep hit with no pre-existing contact plane)
/// or by committing to the steep "walkable" surface via the permissive
/// <c>LandingZ</c> threshold (matching <c>CTransition::check_walkable</c>,
/// pc:273202, <c>0.0871556997f</c>) and then downhill-drifting off it via
/// the already-ported TS-1 CliffSlide chain.
/// </para>
///
/// <para>
/// Run TWICE across this slice's git history: once with the Path-6 steep
/// shortcut ACTIVE (pins today's baseline — always green, since the
/// shortcut's own in-frame slide-tangent cannot wedge by construction), and
/// once with it REMOVED (the retail-strict candidate). If both pass, TS-4's
/// removal is evidenced safe and lands in the same commit that deletes the
/// shortcut and its <c>SetSlidingNormal</c> writes. If the removed-shortcut
/// run wedges, the shortcut stays and this file's result against ToT is the
/// recorded evidence — see the commit message / research doc open questions
/// for the outcome actually reached.
/// </para>
/// Campaign P Slice 2B's production-shaped steep-roof control. It carries
/// contact state between 30 Hz resolves exactly as the live PhysicsBody path
/// does, so the nested edge/StepDown dispatcher can turn a vertical landing
/// into retail's downhill response instead of the old under-modeled fixed
/// point. The paired graph/flat direction matrix in
/// <see cref="RetailEdgeResponseOrderingTests"/> covers vertical, inward,
/// tangential, uphill, downhill, wall, roof, and ledge histories.
/// </summary>
public class Ts4SteepRoofWedgeCaptureTests
{
@ -60,7 +21,7 @@ public class Ts4SteepRoofWedgeCaptureTests
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 MaxTicks = 6 * TicksPerSecond;
private const int WedgeTickThreshold = 15; // 0.5 s of zero motion == wedged
private const float WedgeEpsilon = 0.001f; // 1 mm
@ -112,12 +73,13 @@ public class Ts4SteepRoofWedgeCaptureTests
}
/// <summary>
/// Falls a player-flagged mover from directly above the 63.4° slope's
/// mid-face and asserts it reaches the flat floor (or at minimum keeps
/// making downward/downhill progress) without a >0.5s frozen stretch.
/// Falls a player-flagged mover from directly above the slope and carries
/// each frame's contact state into the next frame, matching the production
/// PhysicsBody path. The exact edge/step-down chain must move the body
/// downhill and onto the reference floor without a half-second wedge.
/// </summary>
[Fact]
public void FallOntoSteepSlope_NeverFreezesForOverHalfASecond_AndReachesFloor()
public void FallOntoSteepSlope_PureVertical_NeverWedgesAndReachesFloor()
{
var engine = MakeSlopeEngine();
float r = BSPStepUpFixtures.SphereRadius;
@ -135,7 +97,6 @@ public class Ts4SteepRoofWedgeCaptureTests
float fallVelocityZ = 0f;
uint cell = CellId;
var positions = new List<Vector3>(MaxTicks) { pos };
int frozenStreak = 0;
bool reachedFloor = false;
@ -152,7 +113,7 @@ public class Ts4SteepRoofWedgeCaptureTests
sphereHeight: r * 2f,
stepUpHeight: 0.30f,
stepDownHeight: 0.04f,
isOnGround: false,
isOnGround: body.OnWalkable,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x01000000u);
@ -170,11 +131,6 @@ public class Ts4SteepRoofWedgeCaptureTests
$"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;
@ -182,9 +138,16 @@ public class Ts4SteepRoofWedgeCaptureTests
if (result.IsOnGround)
fallVelocityZ = 0f;
positions.Add(pos);
body.TransientState &=
~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
if (result.InContact)
body.TransientState |= TransientStateFlags.Contact;
if (result.OnWalkable)
body.TransientState |= TransientStateFlags.OnWalkable;
Assert.True(frozenStreak <= WedgeTickThreshold,
$"Body froze for {frozenStreak} ticks at {pos}.");
// Reached the flat reference floor (x<0, z ~ r) — resolved cleanly.
if (pos.X < 0f && pos.Z <= r + 0.05f)
{
reachedFloor = true;
@ -193,10 +156,7 @@ public class Ts4SteepRoofWedgeCaptureTests
}
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}) — this is the wedge the L.4 shortcut guards " +
"against (never resolving off the steep surface at all), distinct from a bounded " +
"per-tick freeze.");
$"The production-shaped vertical trace did not reach the floor within " +
$"{MaxTicks} ticks; final=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}).");
}
}