fix(physics): restore retail edge-response ordering
This commit is contained in:
parent
4ca7230b36
commit
c559c48d80
4 changed files with 525 additions and 180 deletions
|
|
@ -0,0 +1,396 @@
|
|||
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]}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue