fix(physics): restore retail path-6 collision response
This commit is contained in:
parent
acec33eca8
commit
75b6f6b6c9
8 changed files with 523 additions and 223 deletions
|
|
@ -396,18 +396,12 @@ public class BSPStepUpTests
|
|||
|
||||
/// <summary>
|
||||
/// Airborne mover descending toward a steep slope (normal.Z < 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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
321
tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
Normal file
321
tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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<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}).");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue