acdream/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs

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]}.");
}
}
}