396 lines
15 KiB
C#
396 lines
15 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>
|
|
/// Pins the branch order in retail <c>CTransition::transitional_insert</c>,
|
|
/// <c>CTransition::edge_slide</c>, and <c>CTransition::cliff_slide</c>.
|
|
/// These cases distinguish the retail implementation from the four former
|
|
/// acdream compensations tracked as AP-3, AP-4, AD-53, and AD-54.
|
|
/// </summary>
|
|
public sealed class RetailEdgeResponseOrderingTests
|
|
{
|
|
private const uint Cell = 0xA9B40001u;
|
|
|
|
[Fact]
|
|
public void TransitionalInsert_ValidSteepContact_ReturnsBeforeOrdinaryStepDownTail()
|
|
{
|
|
Vector3 current = new(2f, 3f, 4f);
|
|
Vector3 target = current + new Vector3(0.1f, 0f, 0f);
|
|
var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell);
|
|
transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
|
|
transition.ObjectInfo.StepDown = true;
|
|
transition.ObjectInfo.StepDownHeight = 2f;
|
|
|
|
Vector3 untouchedBackup = new(97f, 98f, 99f);
|
|
const uint untouchedBackupCell = 0xA9B40044u;
|
|
transition.SpherePath.BackupCheckPos = untouchedBackup;
|
|
transition.SpherePath.BackupCheckCellId = untouchedBackupCell;
|
|
|
|
var steep = new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
|
|
var engine = new PhysicsEngine
|
|
{
|
|
TransitionCellCollisionTestHook = (candidate, phase, _, actual) =>
|
|
{
|
|
if (phase == TransitionCellCollisionPhase.Objects)
|
|
candidate.CollisionInfo.SetContactPlane(steep, Cell, isWater: true);
|
|
return actual;
|
|
},
|
|
};
|
|
|
|
TransitionState result = transition.TransitionalInsertForTest(1, engine);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.True(transition.CollisionInfo.ContactPlaneValid);
|
|
Assert.True(transition.CollisionInfo.ContactPlaneIsWater);
|
|
Assert.Equal(steep, transition.CollisionInfo.ContactPlane);
|
|
Assert.Equal(untouchedBackup, transition.SpherePath.BackupCheckPos);
|
|
Assert.Equal(untouchedBackupCell, transition.SpherePath.BackupCheckCellId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1, 0.5f, 2.0f, 0.25f, 1)]
|
|
[InlineData(2, 0.5f, 2.0f, 1.00f, 2)]
|
|
[InlineData(1, 0.5f, 0.75f, 0.75f, 1)]
|
|
[InlineData(2, 0.5f, 0.75f, 0.75f, 1)]
|
|
public void StepDownProbePlan_PreservesRetailOneVersusTwoSphereSplit(
|
|
int sphereCount,
|
|
float radius,
|
|
float requestedHeight,
|
|
float expectedProbeHeight,
|
|
int expectedProbeCount)
|
|
{
|
|
(float probeHeight, int probeCount) = Transition.GetStepDownProbePlan(
|
|
sphereCount,
|
|
radius,
|
|
requestedHeight);
|
|
|
|
Assert.Equal(expectedProbeHeight, probeHeight);
|
|
Assert.Equal(expectedProbeCount, probeCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void EdgeSlide_NotOnWalkableSteepContact_RestoresBeforeCliffSlide()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.ObjectInfo.State = ObjectInfoState.EdgeSlide;
|
|
transition.CollisionInfo.ContactPlaneValid = true;
|
|
transition.CollisionInfo.ContactPlane =
|
|
new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
|
|
transition.CollisionInfo.ContactPlaneIsWater = true;
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
|
|
Vector3 failedCandidate = transition.SpherePath.BackupCheckPos;
|
|
TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest(
|
|
new PhysicsEngine(),
|
|
stepDownHeight: 0.04f,
|
|
zVal: PhysicsGlobals.FloorZ);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.CollisionInfo.ContactPlaneValid);
|
|
Assert.False(transition.CollisionInfo.ContactPlaneIsWater);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void CliffSlide_UsesOnlyLastKnownContactPlaneNormal()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
|
|
// A qualifying remembered walkable normal deliberately points along Y.
|
|
// The former AD-53 fallback consumed it; retail consumes the explicit
|
|
// last-known contact normal below and therefore resolves along -X.
|
|
Plane rememberedWalkable = new(Vector3.Normalize(new Vector3(0f, 1f, 1f)), 0f);
|
|
transition.SpherePath.SetWalkable(
|
|
rememberedWalkable,
|
|
SquareOnPlaneZ0(),
|
|
Vector3.UnitZ);
|
|
transition.SpherePath.ClearWalkable();
|
|
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
|
|
|
|
TransitionState result = transition.CliffSlideForTest(steepContact);
|
|
|
|
Assert.Equal(TransitionState.Adjusted, result);
|
|
Assert.True(transition.CollisionInfo.CollisionNormalValid);
|
|
Assert.True(Vector3.Distance(-Vector3.UnitX, transition.CollisionInfo.CollisionNormal) < 0.0001f);
|
|
}
|
|
|
|
[Fact]
|
|
public void CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = false;
|
|
transition.CollisionInfo.LastKnownContactPlane = default;
|
|
Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
|
|
|
|
TransitionState result = transition.CliffSlideForTest(steepContact);
|
|
|
|
Assert.Equal(TransitionState.OK, result);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void EdgeSlide_StoredSteepWalkable_AlwaysRoutesToPrecipiceSlide()
|
|
{
|
|
var transition = MakeFailedStepDownTransition();
|
|
transition.ObjectInfo.State =
|
|
ObjectInfoState.Contact | ObjectInfoState.OnWalkable | ObjectInfoState.EdgeSlide;
|
|
transition.CollisionInfo.ContactPlaneValid = false;
|
|
transition.CollisionInfo.LastKnownContactPlaneValid = true;
|
|
transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
|
|
|
|
Vector3 steepNormal = Vector3.Normalize(new Vector3(-2f, 0f, 1f));
|
|
var steepPlane = new Plane(steepNormal, 0f);
|
|
Vector3[] steepQuad =
|
|
[
|
|
new(0f, -1f, 0f),
|
|
new(1f, -1f, 2f),
|
|
new(1f, 1f, 2f),
|
|
new(0f, 1f, 0f),
|
|
];
|
|
transition.SpherePath.SetWalkable(steepPlane, steepQuad, Vector3.UnitZ);
|
|
|
|
Vector3 failedCandidate = new(0.5f, 0f, 1f);
|
|
transition.SpherePath.SetCheckPos(failedCandidate, Cell);
|
|
transition.SpherePath.SaveCheckPos();
|
|
transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
|
|
|
|
TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest(
|
|
new PhysicsEngine(),
|
|
stepDownHeight: 0.04f,
|
|
zVal: PhysicsGlobals.FloorZ);
|
|
|
|
// The restored point is inside the remembered polygon, so retail's
|
|
// unconditional PrecipiceSlide returns Collided. AD-54's steep-plane
|
|
// reroute instead returned Adjusted through CliffSlide.
|
|
Assert.Equal(TransitionState.Collided, result);
|
|
Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
|
|
Assert.False(transition.SpherePath.HasWalkablePolygon);
|
|
Assert.False(transition.CollisionInfo.CollisionNormalValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge()
|
|
{
|
|
Vector3[] graph = RunSteepRoofTrace(preparedFlat: false);
|
|
Vector3[] flat = RunSteepRoofTrace(preparedFlat: true);
|
|
|
|
Assert.Equal(graph, flat);
|
|
Assert.Contains(graph, position =>
|
|
position.X < 0f
|
|
&& position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f);
|
|
AssertNoLongFrozenStreak(graph, maximumTicks: 15);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge()
|
|
{
|
|
Vector3[] graph = RunFlatRoofLedgeTrace(preparedFlat: false);
|
|
Vector3[] flat = RunFlatRoofLedgeTrace(preparedFlat: true);
|
|
|
|
Assert.Equal(graph, flat);
|
|
AssertNoLongFrozenStreak(graph, maximumTicks: 15);
|
|
Assert.True(graph[^1].Y > graph[0].Y + 0.25f,
|
|
$"The roof-edge control made no along-edge progress: {graph[0]} -> {graph[^1]}.");
|
|
}
|
|
|
|
private static Transition MakeFailedStepDownTransition()
|
|
{
|
|
Vector3 current = Vector3.Zero;
|
|
Vector3 failedCandidate = new(1f, 0f, 0f);
|
|
var transition = BSPStepUpFixtures.MakeGroundedTransition(
|
|
current,
|
|
failedCandidate,
|
|
cellId: Cell);
|
|
transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
|
|
transition.SpherePath.SetCheckPos(failedCandidate, Cell);
|
|
transition.SpherePath.SaveCheckPos();
|
|
transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
|
|
return transition;
|
|
}
|
|
|
|
private static Vector3[] SquareOnPlaneZ0() =>
|
|
[
|
|
new(-2f, -2f, 0f),
|
|
new( 2f, -2f, 0f),
|
|
new( 2f, 2f, 0f),
|
|
new(-2f, 2f, 0f),
|
|
];
|
|
|
|
private static Vector3[] RunSteepRoofTrace(bool preparedFlat)
|
|
{
|
|
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 };
|
|
Vector3 position = new(0.5f, 0f, 3f);
|
|
float velocityZ = 0f;
|
|
var trace = new List<Vector3>(91) { position };
|
|
|
|
for (int tick = 0; tick < 90; tick++)
|
|
{
|
|
velocityZ += gravity * dt;
|
|
ResolveResult result = engine.ResolveWithTransition(
|
|
position,
|
|
position + new Vector3(0f, 0f, velocityZ * dt),
|
|
Cell,
|
|
radius,
|
|
radius * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: false,
|
|
body,
|
|
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000000u);
|
|
|
|
position = result.Position;
|
|
body.Position = position;
|
|
if (result.IsOnGround)
|
|
velocityZ = 0f;
|
|
trace.Add(position);
|
|
|
|
if (position.X < 0f && position.Z <= radius + 0.05f)
|
|
break;
|
|
}
|
|
|
|
return trace.ToArray();
|
|
}
|
|
|
|
private static Vector3[] RunFlatRoofLedgeTrace(bool preparedFlat)
|
|
{
|
|
var fixture = BSPStepUpFixtures.FlatRoof();
|
|
PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E102u);
|
|
ResolvedPolygon roof = fixture.Resolved[BSPStepUpFixtures.FlatRoof_RoofId];
|
|
float radius = BSPStepUpFixtures.SphereRadius;
|
|
Vector3 position = new(1.55f, -0.75f, 3f);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = position,
|
|
Orientation = Quaternion.Identity,
|
|
ContactPlaneValid = true,
|
|
ContactPlane = roof.Plane,
|
|
ContactPlaneCellId = Cell,
|
|
WalkablePolygonValid = true,
|
|
WalkablePlane = roof.Plane,
|
|
WalkableVertices = roof.Vertices,
|
|
WalkableUp = Vector3.UnitZ,
|
|
TransientState = TransientStateFlags.Active
|
|
| TransientStateFlags.Contact
|
|
| TransientStateFlags.OnWalkable,
|
|
};
|
|
var trace = new List<Vector3>(13) { position };
|
|
|
|
for (int tick = 0; tick < 12; tick++)
|
|
{
|
|
ResolveResult result = engine.ResolveWithTransition(
|
|
position,
|
|
position + new Vector3(0.12f, 0.08f, 0f),
|
|
Cell,
|
|
radius,
|
|
radius * 2f,
|
|
stepUpHeight: 0.30f,
|
|
stepDownHeight: 0.04f,
|
|
isOnGround: true,
|
|
body,
|
|
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
|
movingEntityId: 0x01000001u);
|
|
|
|
position = result.Position;
|
|
body.Position = position;
|
|
body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
|
|
if (result.InContact)
|
|
body.TransientState |= TransientStateFlags.Contact;
|
|
if (result.OnWalkable)
|
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
|
trace.Add(position);
|
|
}
|
|
|
|
return trace.ToArray();
|
|
}
|
|
|
|
private static PhysicsEngine BuildCollisionEngine(
|
|
(PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved) fixture,
|
|
bool preparedFlat,
|
|
uint gfxObjId)
|
|
{
|
|
var normalized = new Dictionary<ushort, ResolvedPolygon>(fixture.Resolved.Count);
|
|
foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved)
|
|
{
|
|
normalized.Add(id, new ResolvedPolygon
|
|
{
|
|
Id = id,
|
|
Vertices = polygon.Vertices,
|
|
Plane = polygon.Plane,
|
|
NumPoints = polygon.NumPoints,
|
|
SidesType = polygon.SidesType,
|
|
});
|
|
}
|
|
|
|
var physics = new GfxObjPhysics
|
|
{
|
|
SourceId = gfxObjId,
|
|
BSP = new PhysicsBSPTree { Root = fixture.Root },
|
|
Resolved = normalized,
|
|
BoundingSphere = fixture.Root.BoundingSphere,
|
|
};
|
|
var cache = new PhysicsDataCache();
|
|
if (preparedFlat)
|
|
{
|
|
cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
|
|
cache.CacheGfxObj(gfxObjId, FlatCollisionAssetBuilder.FlattenGfxObj(physics));
|
|
}
|
|
else
|
|
{
|
|
cache.RegisterGfxObjForTest(gfxObjId, physics);
|
|
}
|
|
|
|
var heights = new byte[81];
|
|
var heightTable = new float[256];
|
|
Array.Fill(heightTable, -1000f);
|
|
var engine = new PhysicsEngine { DataCache = cache };
|
|
engine.AddLandblock(
|
|
0xA9B40000u,
|
|
new TerrainSurface(heights, heightTable),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
0f,
|
|
0f);
|
|
engine.ShadowObjects.Register(
|
|
gfxObjId,
|
|
gfxObjId,
|
|
Vector3.Zero,
|
|
Quaternion.Identity,
|
|
fixture.Root.BoundingSphere.Radius,
|
|
0f,
|
|
0f,
|
|
0xA9B4FFFFu,
|
|
ShadowCollisionType.BSP,
|
|
1f);
|
|
return engine;
|
|
}
|
|
|
|
private static void AssertNoLongFrozenStreak(Vector3[] trace, int maximumTicks)
|
|
{
|
|
int streak = 0;
|
|
for (int i = 1; i < trace.Length; i++)
|
|
{
|
|
streak = Vector3.Distance(trace[i - 1], trace[i]) < 0.001f
|
|
? streak + 1
|
|
: 0;
|
|
Assert.True(streak <= maximumTicks,
|
|
$"Trace froze for {streak} ticks at {trace[i]}.");
|
|
}
|
|
}
|
|
}
|