perf(physics): reuse reset-complete transition scratch
Mirror retail's ten-deep LIFO transition lifetime, retain all query scratch with complete reset contracts, and remove Tier-0 enum boxing without changing collision decisions. Fresh and retained engines are bit-identical across the expanded oracle, while measured transition profiles now allocate 0 bytes per resolve.
This commit is contained in:
parent
2effba5127
commit
16d182c2f0
19 changed files with 3037 additions and 1228 deletions
|
|
@ -134,9 +134,13 @@ public class TerrainSurfaceTests
|
|||
var sample = surface.SampleSurfacePolygon(2f, 2f);
|
||||
|
||||
Assert.Equal(3, sample.Vertices.Length);
|
||||
Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
|
||||
for (int i = 0; i < sample.Vertices.Length; i++)
|
||||
Assert.Equal(50f, sample.Vertices[i].Z);
|
||||
Assert.Equal(1f, sample.Normal.Z, precision: 3);
|
||||
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
|
||||
Assert.True(
|
||||
sample.Vertices.V0.X == 0f && sample.Vertices.V0.Y == 0f
|
||||
|| sample.Vertices.V1.X == 0f && sample.Vertices.V1.Y == 0f
|
||||
|| sample.Vertices.V2.X == 0f && sample.Vertices.V2.Y == 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ using Xunit.Abstractions;
|
|||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Slice I0 evidence harness for the four production
|
||||
/// Slice I allocation gate for the four production
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/> call shapes. This is not a
|
||||
/// budget assertion: I1 deliberately changes the expected allocation from the
|
||||
/// recorded graph-path baseline to zero steady transition/query-scratch bytes.
|
||||
/// The harness stays in-tree so the before/after figure is reproducible.
|
||||
/// broad frame-allocation budget: it isolates transition/query scratch and
|
||||
/// requires zero steady bytes after all retained buffers and tiered code paths
|
||||
/// have warmed.
|
||||
/// </summary>
|
||||
public sealed class TransitionAllocationBaselineTests
|
||||
{
|
||||
|
|
@ -27,32 +27,33 @@ public sealed class TransitionAllocationBaselineTests
|
|||
=> _output = output;
|
||||
|
||||
[Fact]
|
||||
public void GraphPath_RecordsPerResolveAllocationForEveryMoverFamily()
|
||||
public void ReusedScratch_AllocatesZeroBytesPerResolveForEveryMoverFamily()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
var engine = BuildEngine(root, resolved);
|
||||
var groundEngine = BuildGroundEngine();
|
||||
|
||||
long player = Measure(() => ResolvePlayer(engine));
|
||||
long groundedPublication = Measure(
|
||||
() => ResolveGroundedPublication(groundEngine));
|
||||
long remote = Measure(() => ResolveRemote(engine));
|
||||
long projectile = Measure(() => ResolveProjectile(engine));
|
||||
long camera = Measure(() => ResolveCamera(engine));
|
||||
|
||||
_output.WriteLine(
|
||||
$"Slice I0 graph-path allocation baseline ({MeasuredIterations:N0} resolves/profile):");
|
||||
$"Slice I1 steady scratch gate ({MeasuredIterations:N0} resolves/profile):");
|
||||
_output.WriteLine($" player: {player,8:N0} B/resolve");
|
||||
_output.WriteLine(
|
||||
$" grounded: {groundedPublication,8:N0} B/resolve");
|
||||
_output.WriteLine($" remote: {remote,8:N0} B/resolve");
|
||||
_output.WriteLine($" projectile: {projectile,8:N0} B/resolve");
|
||||
_output.WriteLine($" camera: {camera,8:N0} B/resolve");
|
||||
|
||||
// The current graph path constructs a Transition object graph and
|
||||
// CollisionSphere helpers on every resolve. I1 replaces that lifetime
|
||||
// shape; this lower bound merely proves the baseline capture actually
|
||||
// observed those allocations instead of an optimized-away call.
|
||||
Assert.All(
|
||||
new[] { player, remote, projectile, camera },
|
||||
bytes => Assert.True(
|
||||
bytes > 0,
|
||||
"The I0 baseline must observe the current per-resolve allocation."));
|
||||
Assert.Equal(0, player);
|
||||
Assert.Equal(0, groundedPublication);
|
||||
Assert.Equal(0, remote);
|
||||
Assert.Equal(0, projectile);
|
||||
Assert.Equal(0, camera);
|
||||
}
|
||||
|
||||
private static long Measure(Func<ResolveResult> resolve)
|
||||
|
|
@ -106,6 +107,24 @@ public sealed class TransitionAllocationBaselineTests
|
|||
movingEntityId: 0x80000001u);
|
||||
}
|
||||
|
||||
private static ResolveResult ResolveGroundedPublication(PhysicsEngine engine)
|
||||
{
|
||||
var body = Bodies.Grounded;
|
||||
ResetBody(body, PhysicsStateFlags.Gravity);
|
||||
return engine.ResolveWithTransition(
|
||||
currentPos: new Vector3(8f, 8f, 0f),
|
||||
targetPos: new Vector3(8.12f, 8f, 0f),
|
||||
cellId: CellId,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 1.20f,
|
||||
stepUpHeight: 0.40f,
|
||||
stepDownHeight: 1.50f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
movingEntityId: 0x5000000Bu);
|
||||
}
|
||||
|
||||
private static ResolveResult ResolveProjectile(PhysicsEngine engine)
|
||||
{
|
||||
var body = Bodies.Projectile;
|
||||
|
|
@ -197,9 +216,25 @@ public sealed class TransitionAllocationBaselineTests
|
|||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildGroundEngine()
|
||||
{
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
var engine = new PhysicsEngine();
|
||||
engine.AddLandblock(
|
||||
0xA9B4FFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static class Bodies
|
||||
{
|
||||
internal static readonly PhysicsBody Player = new();
|
||||
internal static readonly PhysicsBody Grounded = new();
|
||||
internal static readonly PhysicsBody Remote = new();
|
||||
internal static readonly PhysicsBody Projectile = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,646 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Differential referee for Slice I1. The fresh engine constructs a new
|
||||
/// transition graph for every call; the production engine leases retained
|
||||
/// scratch. Every deterministic result bit and every public body side effect
|
||||
/// must remain identical while hostile mover shapes alternate.
|
||||
/// </summary>
|
||||
public sealed class TransitionScratchDifferentialTests
|
||||
{
|
||||
private const uint Landblock = 0xA9B40000u;
|
||||
private const uint Cell = Landblock | 0x0001u;
|
||||
private const uint GfxObjId = 0x0100F100u;
|
||||
|
||||
[Fact]
|
||||
public void ReusedScratch_MatchesFreshAcrossTransitionFamilies()
|
||||
{
|
||||
RunSequence(
|
||||
reuse => BuildBspEngine(reuse, BSPStepUpFixtures.TallWall, 0f),
|
||||
new ResolveSpec(
|
||||
"grounded two-sphere wall",
|
||||
new Vector3(0.10f, 0f, 0f),
|
||||
new Vector3(0.60f, 0f, 0f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.04f,
|
||||
0.40f,
|
||||
true,
|
||||
GroundedBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000001u,
|
||||
Coverage: CoverageKind.WallBlock),
|
||||
new ResolveSpec(
|
||||
"airborne wall collision",
|
||||
new Vector3(0.10f, 0f, 2.00f),
|
||||
new Vector3(0.60f, 0f, 1.50f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
0f,
|
||||
0.04f,
|
||||
0.04f,
|
||||
false,
|
||||
AirborneBody,
|
||||
ObjectInfoState.EdgeSlide,
|
||||
0x80000001u,
|
||||
Coverage: CoverageKind.AirborneDescent),
|
||||
new ResolveSpec(
|
||||
"carried sliding normal",
|
||||
new Vector3(0.10f, 0f, 2.00f),
|
||||
new Vector3(0.60f, 0.12f, 1.50f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.04f,
|
||||
0.40f,
|
||||
false,
|
||||
SlidingBody,
|
||||
ObjectInfoState.EdgeSlide,
|
||||
0x80000002u,
|
||||
Coverage: CoverageKind.AirborneDescent),
|
||||
new ResolveSpec(
|
||||
"projectile single sphere",
|
||||
new Vector3(0.10f, 0f, 2.00f),
|
||||
new Vector3(0.60f, 0f, 2.00f),
|
||||
0.05f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
false,
|
||||
ProjectileBody,
|
||||
ObjectInfoState.None,
|
||||
0x80000003u,
|
||||
BeginOrientation: Quaternion.Identity,
|
||||
EndOrientation: Quaternion.CreateFromAxisAngle(
|
||||
Vector3.UnitZ,
|
||||
0.25f),
|
||||
DesignatedTargetId: 0x50000099u,
|
||||
Coverage: CoverageKind.Success),
|
||||
new ResolveSpec(
|
||||
"viewer camera",
|
||||
new Vector3(0.10f, 0f, 2.00f),
|
||||
new Vector3(0.60f, 0f, 2.00f),
|
||||
0.30f,
|
||||
0f,
|
||||
0f,
|
||||
0f,
|
||||
false,
|
||||
static () => null,
|
||||
ObjectInfoState.IsViewer
|
||||
| ObjectInfoState.PathClipped
|
||||
| ObjectInfoState.FreeRotate
|
||||
| ObjectInfoState.PerfectClip,
|
||||
0u,
|
||||
Coverage: CoverageKind.Success));
|
||||
|
||||
RunSequence(
|
||||
reuse => BuildBspEngine(reuse, BSPStepUpFixtures.LowStep, 0f),
|
||||
new ResolveSpec(
|
||||
"step up",
|
||||
new Vector3(0.10f, 0f, 0f),
|
||||
new Vector3(0.60f, 0f, 0f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.35f,
|
||||
0.60f,
|
||||
true,
|
||||
GroundedBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000011u,
|
||||
Coverage: CoverageKind.StepUp),
|
||||
new ResolveSpec(
|
||||
"step down",
|
||||
new Vector3(0.80f, 0f, 0.25f),
|
||||
new Vector3(0.10f, 0f, 0.25f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.35f,
|
||||
0.60f,
|
||||
true,
|
||||
UpperGroundedBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000012u,
|
||||
Coverage: CoverageKind.Success));
|
||||
|
||||
RunSequence(
|
||||
reuse => BuildBspEngine(reuse, BSPStepUpFixtures.FlatRoof, -50f),
|
||||
new ResolveSpec(
|
||||
"airborne roof landing",
|
||||
new Vector3(0f, 0f, 3.10f),
|
||||
new Vector3(0f, 0f, 2.75f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.04f,
|
||||
0.40f,
|
||||
false,
|
||||
AirborneBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000021u,
|
||||
Coverage: CoverageKind.RoofLanding));
|
||||
|
||||
RunSequence(
|
||||
reuse => BuildOpenEngine(reuse, includeLandblock: true),
|
||||
new ResolveSpec(
|
||||
"open outdoor success",
|
||||
new Vector3(8f, 8f, 0f),
|
||||
new Vector3(8.25f, 8f, 0f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.40f,
|
||||
1.50f,
|
||||
true,
|
||||
GroundedBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000031u,
|
||||
Coverage: CoverageKind.Success));
|
||||
|
||||
RunSequence(
|
||||
reuse => BuildOpenEngine(reuse, includeLandblock: false),
|
||||
new ResolveSpec(
|
||||
"missing-cell failure",
|
||||
new Vector3(8f, 8f, 2f),
|
||||
new Vector3(8.25f, 8f, 2f),
|
||||
BSPStepUpFixtures.SphereRadius,
|
||||
1.20f,
|
||||
0.40f,
|
||||
1.50f,
|
||||
false,
|
||||
AirborneBody,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000032u,
|
||||
CellId: 0u,
|
||||
Coverage: CoverageKind.Failure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReusedScratch_MatchesFreshPlacementSearch()
|
||||
{
|
||||
PhysicsEngine fresh = BuildPlacementEngine(reuse: false);
|
||||
PhysicsEngine reused = BuildPlacementEngine(reuse: true);
|
||||
|
||||
ResolveResult expected = fresh.ResolvePlacement(
|
||||
new Vector3(10f, 10f, 0f),
|
||||
Cell,
|
||||
0.48f,
|
||||
1.835f,
|
||||
0.40f,
|
||||
0.40f,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000101u);
|
||||
ResolveResult actual = reused.ResolvePlacement(
|
||||
new Vector3(10f, 10f, 0f),
|
||||
Cell,
|
||||
0.48f,
|
||||
1.835f,
|
||||
0.40f,
|
||||
0.40f,
|
||||
ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
|
||||
0x50000101u);
|
||||
|
||||
AssertResolveBitwise(expected, actual, "placement");
|
||||
|
||||
// A second placement with a different self identity proves that the
|
||||
// previous mover and collision-GUID list cannot leak through the lease.
|
||||
expected = fresh.ResolvePlacement(
|
||||
new Vector3(11f, 10f, 0f),
|
||||
Cell,
|
||||
0.48f,
|
||||
1.835f,
|
||||
0.40f,
|
||||
0.40f,
|
||||
ObjectInfoState.EdgeSlide,
|
||||
0x80000102u);
|
||||
actual = reused.ResolvePlacement(
|
||||
new Vector3(11f, 10f, 0f),
|
||||
Cell,
|
||||
0.48f,
|
||||
1.835f,
|
||||
0.40f,
|
||||
0.40f,
|
||||
ObjectInfoState.EdgeSlide,
|
||||
0x80000102u);
|
||||
|
||||
AssertResolveBitwise(expected, actual, "placement after hostile identity");
|
||||
}
|
||||
|
||||
private static void RunSequence(
|
||||
Func<bool, PhysicsEngine> buildEngine,
|
||||
params ResolveSpec[] specs)
|
||||
{
|
||||
PhysicsEngine fresh = buildEngine(false);
|
||||
PhysicsEngine reused = buildEngine(true);
|
||||
|
||||
// Repeat in alternating directions. This leaves every retained field
|
||||
// exposed to a materially different next mover and branch family.
|
||||
for (int pass = 0; pass < 4; pass++)
|
||||
{
|
||||
for (int index = 0; index < specs.Length; index++)
|
||||
{
|
||||
int specIndex = (pass & 1) == 0
|
||||
? index
|
||||
: specs.Length - 1 - index;
|
||||
ResolveSpec spec = specs[specIndex];
|
||||
PhysicsBody? expectedBody = spec.BodyFactory();
|
||||
PhysicsBody? actualBody = spec.BodyFactory();
|
||||
|
||||
ResolveResult expected = spec.Resolve(fresh, expectedBody);
|
||||
ResolveResult actual = spec.Resolve(reused, actualBody);
|
||||
|
||||
AssertCoverage(spec, expected);
|
||||
AssertResolveBitwise(expected, actual, spec.Name);
|
||||
AssertBodyBitwise(expectedBody, actualBody, spec.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildBspEngine(
|
||||
bool reuse,
|
||||
Func<(PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)> fixture,
|
||||
float terrainZ)
|
||||
{
|
||||
var (root, resolved) = fixture();
|
||||
PhysicsEngine engine = BuildOpenEngine(reuse, includeLandblock: true, terrainZ);
|
||||
var cache = new PhysicsDataCache();
|
||||
cache.RegisterGfxObjForTest(GfxObjId, new GfxObjPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = root },
|
||||
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
|
||||
Vertices = new VertexArray(),
|
||||
Resolved = resolved,
|
||||
BoundingSphere = new Sphere
|
||||
{
|
||||
Origin = new Vector3(0f, 0f, 2.5f),
|
||||
Radius = 15f,
|
||||
},
|
||||
});
|
||||
engine.DataCache = cache;
|
||||
engine.ShadowObjects.Register(
|
||||
entityId: GfxObjId,
|
||||
gfxObjId: GfxObjId,
|
||||
worldPos: Vector3.Zero,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 15f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: Landblock,
|
||||
collisionType: ShadowCollisionType.BSP,
|
||||
scale: 1f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildOpenEngine(
|
||||
bool reuse,
|
||||
bool includeLandblock,
|
||||
float terrainZ = 0f)
|
||||
{
|
||||
var engine = new PhysicsEngine(reuse);
|
||||
if (!includeLandblock)
|
||||
return engine;
|
||||
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
Array.Fill(heightTable, terrainZ);
|
||||
engine.AddLandblock(
|
||||
Landblock | 0xFFFFu,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
0f,
|
||||
0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static PhysicsEngine BuildPlacementEngine(bool reuse)
|
||||
{
|
||||
PhysicsEngine engine = BuildOpenEngine(reuse, includeLandblock: true);
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
RegisterSphere(
|
||||
engine,
|
||||
0x50000101u,
|
||||
new Vector3(10f, 10f, 0.48f),
|
||||
EntityCollisionFlags.IsPlayer | EntityCollisionFlags.IsCreature);
|
||||
RegisterSphere(
|
||||
engine,
|
||||
0x50000102u,
|
||||
new Vector3(10.10f, 10f, 0.48f),
|
||||
EntityCollisionFlags.IsCreature);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static void RegisterSphere(
|
||||
PhysicsEngine engine,
|
||||
uint entityId,
|
||||
Vector3 center,
|
||||
EntityCollisionFlags flags)
|
||||
{
|
||||
engine.ShadowObjects.Register(
|
||||
entityId,
|
||||
gfxObjId: 0u,
|
||||
worldPos: center,
|
||||
rotation: Quaternion.Identity,
|
||||
radius: 0.48f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: Landblock,
|
||||
collisionType: ShadowCollisionType.Sphere,
|
||||
cylHeight: 0f,
|
||||
scale: 1f,
|
||||
state: 0u,
|
||||
flags: flags,
|
||||
seedCellId: Cell,
|
||||
isStatic: false);
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
=> new()
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Contact
|
||||
| TransientStateFlags.OnWalkable,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new Plane(Vector3.UnitZ, 0f),
|
||||
ContactPlaneCellId = Cell,
|
||||
WalkablePolygonValid = true,
|
||||
WalkablePlane = new Plane(Vector3.UnitZ, 0f),
|
||||
WalkableVertices =
|
||||
[
|
||||
new(-2f, -2f, 0f),
|
||||
new(2f, -2f, 0f),
|
||||
new(2f, 2f, 0f),
|
||||
new(-2f, 2f, 0f),
|
||||
],
|
||||
WalkableUp = Vector3.UnitZ,
|
||||
Velocity = new Vector3(0.25f, 0.125f, 0f),
|
||||
};
|
||||
|
||||
private static PhysicsBody UpperGroundedBody()
|
||||
{
|
||||
PhysicsBody body = GroundedBody();
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, -0.25f);
|
||||
body.WalkablePlane = new Plane(Vector3.UnitZ, -0.25f);
|
||||
body.WalkableVertices =
|
||||
[
|
||||
new(0.2f, -2f, 0.25f),
|
||||
new(2f, -2f, 0.25f),
|
||||
new(2f, 2f, 0.25f),
|
||||
new(0.2f, 2f, 0.25f),
|
||||
];
|
||||
return body;
|
||||
}
|
||||
|
||||
private static PhysicsBody AirborneBody()
|
||||
=> new()
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
Velocity = new Vector3(0.25f, 0f, -0.08f),
|
||||
};
|
||||
|
||||
private static PhysicsBody SlidingBody()
|
||||
=> new()
|
||||
{
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active
|
||||
| TransientStateFlags.Sliding
|
||||
| TransientStateFlags.StationaryFall,
|
||||
SlidingNormal = Vector3.UnitY,
|
||||
Velocity = new Vector3(0.25f, 0.125f, -0.08f),
|
||||
};
|
||||
|
||||
private static PhysicsBody ProjectileBody()
|
||||
=> new()
|
||||
{
|
||||
State = PhysicsStateFlags.Missile
|
||||
| PhysicsStateFlags.PathClipped
|
||||
| PhysicsStateFlags.Inelastic,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
Velocity = new Vector3(20f, 0f, 0f),
|
||||
};
|
||||
|
||||
private static void AssertResolveBitwise(
|
||||
ResolveResult expected,
|
||||
ResolveResult actual,
|
||||
string context)
|
||||
{
|
||||
AssertVectorBitwise(expected.Position, actual.Position, context);
|
||||
Assert.Equal(expected.CellId, actual.CellId);
|
||||
Assert.Equal(expected.IsOnGround, actual.IsOnGround);
|
||||
Assert.Equal(expected.CollisionNormalValid, actual.CollisionNormalValid);
|
||||
AssertVectorBitwise(expected.CollisionNormal, actual.CollisionNormal, context);
|
||||
Assert.Equal(expected.Ok, actual.Ok);
|
||||
AssertQuaternionBitwise(expected.Orientation, actual.Orientation, context);
|
||||
Assert.Equal(expected.InContact, actual.InContact);
|
||||
Assert.Equal(expected.OnWalkable, actual.OnWalkable);
|
||||
AssertPlaneBitwise(expected.ContactPlane, actual.ContactPlane, context);
|
||||
Assert.Equal(expected.ContactPlaneCellId, actual.ContactPlaneCellId);
|
||||
Assert.Equal(expected.ContactPlaneIsWater, actual.ContactPlaneIsWater);
|
||||
}
|
||||
|
||||
private static void AssertCoverage(ResolveSpec spec, ResolveResult result)
|
||||
{
|
||||
switch (spec.Coverage)
|
||||
{
|
||||
case CoverageKind.None:
|
||||
return;
|
||||
case CoverageKind.Success:
|
||||
Assert.True(result.Ok, $"{spec.Name} did not reach a successful transition.");
|
||||
return;
|
||||
case CoverageKind.Failure:
|
||||
Assert.False(result.Ok, $"{spec.Name} did not reach the failure branch.");
|
||||
return;
|
||||
case CoverageKind.WallBlock:
|
||||
Assert.True(
|
||||
result.CollisionNormalValid
|
||||
|| result.Position.X < spec.TargetPos.X,
|
||||
$"{spec.Name} did not exercise wall collision/blocking.");
|
||||
return;
|
||||
case CoverageKind.AirborneDescent:
|
||||
Assert.True(
|
||||
result.Position.Z < spec.CurrentPos.Z,
|
||||
$"{spec.Name} did not preserve airborne descent.");
|
||||
return;
|
||||
case CoverageKind.StepUp:
|
||||
Assert.True(
|
||||
result.Position.Z >= 0.25f - PhysicsGlobals.EPSILON * 10f,
|
||||
$"{spec.Name} did not reach the upper floor.");
|
||||
return;
|
||||
case CoverageKind.RoofLanding:
|
||||
Assert.True(
|
||||
result.InContact || result.IsOnGround,
|
||||
$"{spec.Name} did not reach the roof contact branch.");
|
||||
return;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertBodyBitwise(
|
||||
PhysicsBody? expected,
|
||||
PhysicsBody? actual,
|
||||
string context)
|
||||
{
|
||||
if (expected is null || actual is null)
|
||||
{
|
||||
Assert.Equal(expected is null, actual is null);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (PropertyInfo property in typeof(PhysicsBody).GetProperties(
|
||||
BindingFlags.Instance | BindingFlags.Public))
|
||||
{
|
||||
if (property.GetIndexParameters().Length != 0)
|
||||
continue;
|
||||
|
||||
object? expectedValue = property.GetValue(expected);
|
||||
object? actualValue = property.GetValue(actual);
|
||||
string memberContext = $"{context}: PhysicsBody.{property.Name}";
|
||||
|
||||
switch (expectedValue)
|
||||
{
|
||||
case float expectedFloat:
|
||||
AssertFloatBitwise(
|
||||
expectedFloat,
|
||||
Assert.IsType<float>(actualValue),
|
||||
memberContext);
|
||||
break;
|
||||
case double expectedDouble:
|
||||
Assert.Equal(
|
||||
BitConverter.DoubleToInt64Bits(expectedDouble),
|
||||
BitConverter.DoubleToInt64Bits(
|
||||
Assert.IsType<double>(actualValue)));
|
||||
break;
|
||||
case Vector3 expectedVector:
|
||||
AssertVectorBitwise(
|
||||
expectedVector,
|
||||
Assert.IsType<Vector3>(actualValue),
|
||||
memberContext);
|
||||
break;
|
||||
case Quaternion expectedRotation:
|
||||
AssertQuaternionBitwise(
|
||||
expectedRotation,
|
||||
Assert.IsType<Quaternion>(actualValue),
|
||||
memberContext);
|
||||
break;
|
||||
case Plane expectedPlane:
|
||||
AssertPlaneBitwise(
|
||||
expectedPlane,
|
||||
Assert.IsType<Plane>(actualValue),
|
||||
memberContext);
|
||||
break;
|
||||
case Vector3[] expectedVertices:
|
||||
{
|
||||
Vector3[] actualVertices = Assert.IsType<Vector3[]>(actualValue);
|
||||
Assert.Equal(expectedVertices.Length, actualVertices.Length);
|
||||
for (int i = 0; i < expectedVertices.Length; i++)
|
||||
{
|
||||
AssertVectorBitwise(
|
||||
expectedVertices[i],
|
||||
actualVertices[i],
|
||||
$"{memberContext}[{i}]");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case null:
|
||||
Assert.Null(actualValue);
|
||||
break;
|
||||
default:
|
||||
Assert.Equal(expectedValue, actualValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertVectorBitwise(
|
||||
Vector3 expected,
|
||||
Vector3 actual,
|
||||
string context)
|
||||
{
|
||||
AssertFloatBitwise(expected.X, actual.X, $"{context}.X");
|
||||
AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y");
|
||||
AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z");
|
||||
}
|
||||
|
||||
private static void AssertQuaternionBitwise(
|
||||
Quaternion expected,
|
||||
Quaternion actual,
|
||||
string context)
|
||||
{
|
||||
AssertFloatBitwise(expected.X, actual.X, $"{context}.X");
|
||||
AssertFloatBitwise(expected.Y, actual.Y, $"{context}.Y");
|
||||
AssertFloatBitwise(expected.Z, actual.Z, $"{context}.Z");
|
||||
AssertFloatBitwise(expected.W, actual.W, $"{context}.W");
|
||||
}
|
||||
|
||||
private static void AssertPlaneBitwise(
|
||||
Plane expected,
|
||||
Plane actual,
|
||||
string context)
|
||||
{
|
||||
AssertVectorBitwise(expected.Normal, actual.Normal, $"{context}.Normal");
|
||||
AssertFloatBitwise(expected.D, actual.D, $"{context}.D");
|
||||
}
|
||||
|
||||
private static void AssertFloatBitwise(
|
||||
float expected,
|
||||
float actual,
|
||||
string context)
|
||||
=> Assert.True(
|
||||
BitConverter.SingleToInt32Bits(expected)
|
||||
== BitConverter.SingleToInt32Bits(actual),
|
||||
$"{context}: expected 0x{BitConverter.SingleToInt32Bits(expected):X8}, "
|
||||
+ $"actual 0x{BitConverter.SingleToInt32Bits(actual):X8}");
|
||||
|
||||
private sealed record ResolveSpec(
|
||||
string Name,
|
||||
Vector3 CurrentPos,
|
||||
Vector3 TargetPos,
|
||||
float SphereRadius,
|
||||
float SphereHeight,
|
||||
float StepUpHeight,
|
||||
float StepDownHeight,
|
||||
bool IsOnGround,
|
||||
Func<PhysicsBody?> BodyFactory,
|
||||
ObjectInfoState MoverFlags,
|
||||
uint MovingEntityId,
|
||||
Vector3? LocalSphereOrigin = null,
|
||||
Quaternion? BeginOrientation = null,
|
||||
Quaternion? EndOrientation = null,
|
||||
uint DesignatedTargetId = 0,
|
||||
uint CellId = TransitionScratchDifferentialTests.Cell,
|
||||
CoverageKind Coverage = CoverageKind.None)
|
||||
{
|
||||
internal ResolveResult Resolve(PhysicsEngine engine, PhysicsBody? body)
|
||||
=> engine.ResolveWithTransition(
|
||||
CurrentPos,
|
||||
TargetPos,
|
||||
CellId,
|
||||
SphereRadius,
|
||||
SphereHeight,
|
||||
StepUpHeight,
|
||||
StepDownHeight,
|
||||
IsOnGround,
|
||||
body,
|
||||
MoverFlags,
|
||||
MovingEntityId,
|
||||
LocalSphereOrigin,
|
||||
BeginOrientation,
|
||||
EndOrientation,
|
||||
DesignatedTargetId);
|
||||
}
|
||||
|
||||
private enum CoverageKind
|
||||
{
|
||||
None,
|
||||
Success,
|
||||
Failure,
|
||||
WallBlock,
|
||||
AirborneDescent,
|
||||
StepUp,
|
||||
RoofLanding,
|
||||
}
|
||||
}
|
||||
417
tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs
Normal file
417
tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Structural proof for the retained transition lifetime introduced by Slice
|
||||
/// I1. Reflection deliberately makes a newly added state field fail this test
|
||||
/// until its poison/reset representation is defined.
|
||||
/// </summary>
|
||||
public sealed class TransitionScratchResetTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResetForReuse_PoisonsEveryStoredMemberAndMatchesFreshState()
|
||||
{
|
||||
var transition = new Transition();
|
||||
PoisonStoredState(transition);
|
||||
|
||||
ObjectInfo objectInfo = transition.ObjectInfo;
|
||||
SpherePath path = transition.SpherePath;
|
||||
CollisionInfo collision = transition.CollisionInfo;
|
||||
Sphere[] localSpheres = path.LocalSphere;
|
||||
Sphere[] globalSpheres = path.GlobalSphere;
|
||||
Sphere[] currentSpheres = path.GlobalCurrCenter;
|
||||
Sphere[] allSpheres =
|
||||
[
|
||||
.. localSpheres,
|
||||
.. globalSpheres,
|
||||
.. currentSpheres,
|
||||
];
|
||||
Vector3[] retainedWalkable = Assert.IsType<Vector3[]>(
|
||||
path.RetainedWalkableVertexStorage);
|
||||
Vector3[] retainedLastWalkable = Assert.IsType<Vector3[]>(
|
||||
path.RetainedLastWalkableVertexStorage);
|
||||
CellArray retainedCells = path.CellCandidates;
|
||||
CellOrderScratchArena retainedOrder = path.OrderedCellScratch;
|
||||
List<uint>[] retainedOrderRecords =
|
||||
retainedOrder.RetainedRecords.ToArray();
|
||||
List<uint> retainedCollisions = collision.CollideObjectGuids;
|
||||
|
||||
transition.ResetForReuse();
|
||||
|
||||
Assert.Same(objectInfo, transition.ObjectInfo);
|
||||
Assert.Same(path, transition.SpherePath);
|
||||
Assert.Same(collision, transition.CollisionInfo);
|
||||
Assert.Same(localSpheres, path.LocalSphere);
|
||||
Assert.Same(globalSpheres, path.GlobalSphere);
|
||||
Assert.Same(currentSpheres, path.GlobalCurrCenter);
|
||||
Assert.Same(retainedWalkable, path.RetainedWalkableVertexStorage);
|
||||
Assert.Same(retainedLastWalkable, path.RetainedLastWalkableVertexStorage);
|
||||
Assert.Same(retainedCells, path.CellCandidates);
|
||||
Assert.Same(retainedOrder, path.OrderedCellScratch);
|
||||
Assert.Equal(0, retainedOrder.ActiveDepth);
|
||||
Assert.Equal(retainedOrderRecords.Length, retainedOrder.RetainedRecordCount);
|
||||
for (int i = 0; i < retainedOrderRecords.Length; i++)
|
||||
{
|
||||
Assert.Same(
|
||||
retainedOrderRecords[i],
|
||||
retainedOrder.RetainedRecords[i]);
|
||||
Assert.Empty(retainedOrderRecords[i]);
|
||||
}
|
||||
Assert.Same(retainedCollisions, collision.CollideObjectGuids);
|
||||
|
||||
Sphere[] resetSpheres =
|
||||
[
|
||||
.. path.LocalSphere,
|
||||
.. path.GlobalSphere,
|
||||
.. path.GlobalCurrCenter,
|
||||
];
|
||||
for (int i = 0; i < allSpheres.Length; i++)
|
||||
Assert.Same(allSpheres[i], resetSpheres[i]);
|
||||
|
||||
Assert.All(retainedWalkable, value => AssertBitwise(Vector3.Zero, value));
|
||||
Assert.All(retainedLastWalkable, value => AssertBitwise(Vector3.Zero, value));
|
||||
AssertFreshState(new Transition(), transition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkableStorage_ReusesOnlyAnExactLogicalLength()
|
||||
{
|
||||
var path = new SpherePath();
|
||||
var first = new[]
|
||||
{
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(4f, 5f, 6f),
|
||||
new Vector3(7f, 8f, 9f),
|
||||
};
|
||||
var second = new[]
|
||||
{
|
||||
new Vector3(-1f, -2f, -3f),
|
||||
new Vector3(-4f, -5f, -6f),
|
||||
new Vector3(-7f, -8f, -9f),
|
||||
};
|
||||
|
||||
path.SetWalkable(new Plane(Vector3.UnitZ, -3f), first, Vector3.UnitZ);
|
||||
Vector3[] firstStorage = Assert.IsType<Vector3[]>(path.WalkableVertices);
|
||||
Vector3[] firstLastStorage = Assert.IsType<Vector3[]>(path.LastWalkableVertices);
|
||||
|
||||
path.ResetForReuse();
|
||||
path.SetWalkable(new Plane(Vector3.UnitZ, 3f), second, Vector3.UnitZ);
|
||||
|
||||
Assert.Same(firstStorage, path.WalkableVertices);
|
||||
Assert.Same(firstLastStorage, path.LastWalkableVertices);
|
||||
AssertVectorsBitwise(second, Assert.IsType<Vector3[]>(path.WalkableVertices));
|
||||
AssertVectorsBitwise(second, Assert.IsType<Vector3[]>(path.LastWalkableVertices));
|
||||
|
||||
Vector3[] fourVertices =
|
||||
[
|
||||
.. second,
|
||||
new Vector3(10f, 11f, 12f),
|
||||
];
|
||||
path.SetWalkable(new Plane(Vector3.UnitZ, 0f), fourVertices, Vector3.UnitZ);
|
||||
|
||||
Assert.NotSame(firstStorage, path.WalkableVertices);
|
||||
Assert.NotSame(firstLastStorage, path.LastWalkableVertices);
|
||||
Assert.Equal(4, path.WalkableVertices!.Length);
|
||||
Assert.Equal(4, path.LastWalkableVertices!.Length);
|
||||
AssertVectorsBitwise(fourVertices, path.WalkableVertices);
|
||||
AssertVectorsBitwise(fourVertices, path.LastWalkableVertices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicsBodyWalkablePublication_RetainsOnlyAnExactLength()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
Vector3[] triangle =
|
||||
[
|
||||
new(1f, 2f, 3f),
|
||||
new(4f, 5f, 6f),
|
||||
new(7f, 8f, 9f),
|
||||
];
|
||||
|
||||
body.SetWalkableVerticesExact(triangle);
|
||||
Vector3[] first = Assert.IsType<Vector3[]>(body.WalkableVertices);
|
||||
body.WalkableVertices = null;
|
||||
body.SetWalkableVerticesExact(triangle);
|
||||
Assert.Same(first, body.WalkableVertices);
|
||||
|
||||
body.SetWalkableVerticesExact(
|
||||
[
|
||||
.. triangle,
|
||||
new Vector3(10f, 11f, 12f),
|
||||
]);
|
||||
Assert.NotSame(first, body.WalkableVertices);
|
||||
Assert.Equal(4, body.WalkableVertices!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arena_IsTenDeepDistinctAndReusesRecordsInLifoOrder()
|
||||
{
|
||||
var arena = new TransitionScratchArena();
|
||||
var leased = new Transition[TransitionScratchArena.Capacity];
|
||||
|
||||
for (int i = 0; i < leased.Length; i++)
|
||||
{
|
||||
leased[i] = arena.Rent();
|
||||
Assert.Equal(i + 1, arena.ActiveDepth);
|
||||
Assert.DoesNotContain(
|
||||
leased[i],
|
||||
leased.AsSpan(0, i).ToArray());
|
||||
}
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => arena.Rent());
|
||||
|
||||
for (int i = leased.Length - 1; i >= 0; i--)
|
||||
{
|
||||
arena.Return(leased[i]);
|
||||
Assert.Equal(i, arena.ActiveDepth);
|
||||
}
|
||||
|
||||
Transition reused = arena.Rent();
|
||||
Assert.Same(leased[0], reused);
|
||||
reused.ObjectInfo.State = ObjectInfoState.IsPlayer;
|
||||
arena.Return(reused);
|
||||
|
||||
reused = arena.Rent();
|
||||
Assert.Equal(ObjectInfoState.None, reused.ObjectInfo.State);
|
||||
arena.Return(reused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arena_RejectsOutOfOrderAndCrossThreadUse()
|
||||
{
|
||||
var arena = new TransitionScratchArena();
|
||||
Transition outer = arena.Rent();
|
||||
Transition inner = arena.Rent();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => arena.Return(outer));
|
||||
arena.Return(inner);
|
||||
|
||||
Exception? crossThreadError = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
arena.Rent();
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
crossThreadError = error;
|
||||
}
|
||||
});
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
Assert.IsType<InvalidOperationException>(crossThreadError);
|
||||
Assert.Equal(1, arena.ActiveDepth);
|
||||
arena.Return(outer);
|
||||
Assert.Equal(0, arena.ActiveDepth);
|
||||
}
|
||||
|
||||
private static void PoisonStoredState(object instance)
|
||||
{
|
||||
foreach (FieldInfo field in instance.GetType().GetFields(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.DeclaredOnly))
|
||||
{
|
||||
object? current = field.GetValue(instance);
|
||||
if (field.IsInitOnly)
|
||||
{
|
||||
switch (current)
|
||||
{
|
||||
case ObjectInfo objectInfo:
|
||||
PoisonStoredState(objectInfo);
|
||||
break;
|
||||
case SpherePath path:
|
||||
PoisonStoredState(path);
|
||||
break;
|
||||
case CollisionInfo collision:
|
||||
PoisonStoredState(collision);
|
||||
break;
|
||||
case Sphere[] spheres:
|
||||
foreach (Sphere sphere in spheres)
|
||||
{
|
||||
sphere.Origin = new Vector3(11f, 12f, 13f);
|
||||
sphere.Radius = 14f;
|
||||
}
|
||||
break;
|
||||
case CellArray cells:
|
||||
cells.Add(0xA9B40001u);
|
||||
break;
|
||||
case CellOrderScratchArena orderScratch:
|
||||
orderScratch.Rent().Add(0xA9B40001u);
|
||||
orderScratch.Rent().Add(0xA9B40100u);
|
||||
break;
|
||||
case List<uint> values:
|
||||
values.Add(0xDEADBEEFu);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"No retained-state poison rule for "
|
||||
+ $"{instance.GetType().Name}.{field.Name} "
|
||||
+ $"({field.FieldType.Name}).");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
field.SetValue(instance, PoisonValue(field.FieldType));
|
||||
}
|
||||
}
|
||||
|
||||
private static object PoisonValue(Type type)
|
||||
{
|
||||
if (type == typeof(bool))
|
||||
return true;
|
||||
if (type == typeof(int))
|
||||
return 37;
|
||||
if (type == typeof(uint))
|
||||
return 0xC0FFEEu;
|
||||
if (type == typeof(float))
|
||||
return 17.25f;
|
||||
if (type == typeof(Vector3))
|
||||
return new Vector3(1.25f, -2.5f, 3.75f);
|
||||
if (type == typeof(Quaternion))
|
||||
return new Quaternion(1f, 2f, 3f, 4f);
|
||||
if (type == typeof(Plane))
|
||||
return new Plane(new Vector3(5f, 6f, 7f), 8f);
|
||||
if (type == typeof(Vector3[]))
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new Vector3(1f, 2f, 3f),
|
||||
new Vector3(4f, 5f, 6f),
|
||||
new Vector3(7f, 8f, 9f),
|
||||
};
|
||||
}
|
||||
if (type == typeof(Vector3?))
|
||||
return (Vector3?)new Vector3(9f, 8f, 7f);
|
||||
if (type == typeof(uint?))
|
||||
return (uint?)0xBADF00Du;
|
||||
if (type.IsEnum)
|
||||
return Enum.ToObject(type, uint.MaxValue);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No poison value for stored type {type.FullName}.");
|
||||
}
|
||||
|
||||
private static void AssertFreshState(object expected, object actual)
|
||||
{
|
||||
Assert.Equal(expected.GetType(), actual.GetType());
|
||||
|
||||
foreach (FieldInfo field in expected.GetType().GetFields(
|
||||
BindingFlags.Instance
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.DeclaredOnly))
|
||||
{
|
||||
object? expectedValue = field.GetValue(expected);
|
||||
object? actualValue = field.GetValue(actual);
|
||||
|
||||
if (field.Name is "_walkableVertexStorage"
|
||||
or "_lastWalkableVertexStorage")
|
||||
{
|
||||
Vector3[] retained = Assert.IsType<Vector3[]>(actualValue);
|
||||
Assert.All(retained, value => AssertBitwise(Vector3.Zero, value));
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (expectedValue)
|
||||
{
|
||||
case ObjectInfo expectedObject:
|
||||
AssertFreshState(expectedObject, Assert.IsType<ObjectInfo>(actualValue));
|
||||
break;
|
||||
case SpherePath expectedPath:
|
||||
AssertFreshState(expectedPath, Assert.IsType<SpherePath>(actualValue));
|
||||
break;
|
||||
case CollisionInfo expectedCollision:
|
||||
AssertFreshState(
|
||||
expectedCollision,
|
||||
Assert.IsType<CollisionInfo>(actualValue));
|
||||
break;
|
||||
case Sphere[] expectedSpheres:
|
||||
{
|
||||
Sphere[] actualSpheres = Assert.IsType<Sphere[]>(actualValue);
|
||||
Assert.Equal(expectedSpheres.Length, actualSpheres.Length);
|
||||
for (int i = 0; i < expectedSpheres.Length; i++)
|
||||
{
|
||||
AssertBitwise(expectedSpheres[i].Origin, actualSpheres[i].Origin);
|
||||
AssertBitwise(expectedSpheres[i].Radius, actualSpheres[i].Radius);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CellArray expectedCells:
|
||||
Assert.Equal(expectedCells.OrderedIds, Assert.IsType<CellArray>(actualValue).OrderedIds);
|
||||
break;
|
||||
case CellOrderScratchArena:
|
||||
{
|
||||
CellOrderScratchArena actualScratch =
|
||||
Assert.IsType<CellOrderScratchArena>(actualValue);
|
||||
Assert.Equal(0, actualScratch.ActiveDepth);
|
||||
Assert.All(actualScratch.RetainedRecords, Assert.Empty);
|
||||
break;
|
||||
}
|
||||
case List<uint> expectedValues:
|
||||
Assert.Equal(expectedValues, Assert.IsType<List<uint>>(actualValue));
|
||||
break;
|
||||
case Vector3 expectedVector:
|
||||
AssertBitwise(expectedVector, Assert.IsType<Vector3>(actualValue));
|
||||
break;
|
||||
case Quaternion expectedRotation:
|
||||
AssertBitwise(expectedRotation, Assert.IsType<Quaternion>(actualValue));
|
||||
break;
|
||||
case Plane expectedPlane:
|
||||
AssertBitwise(expectedPlane, Assert.IsType<Plane>(actualValue));
|
||||
break;
|
||||
case null:
|
||||
Assert.Null(actualValue);
|
||||
break;
|
||||
default:
|
||||
Assert.Equal(expectedValue, actualValue);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertVectorsBitwise(
|
||||
IReadOnlyList<Vector3> expected,
|
||||
IReadOnlyList<Vector3> actual)
|
||||
{
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (int i = 0; i < expected.Count; i++)
|
||||
AssertBitwise(expected[i], actual[i]);
|
||||
}
|
||||
|
||||
private static void AssertBitwise(Vector3 expected, Vector3 actual)
|
||||
{
|
||||
AssertBitwise(expected.X, actual.X);
|
||||
AssertBitwise(expected.Y, actual.Y);
|
||||
AssertBitwise(expected.Z, actual.Z);
|
||||
}
|
||||
|
||||
private static void AssertBitwise(Quaternion expected, Quaternion actual)
|
||||
{
|
||||
AssertBitwise(expected.X, actual.X);
|
||||
AssertBitwise(expected.Y, actual.Y);
|
||||
AssertBitwise(expected.Z, actual.Z);
|
||||
AssertBitwise(expected.W, actual.W);
|
||||
}
|
||||
|
||||
private static void AssertBitwise(Plane expected, Plane actual)
|
||||
{
|
||||
AssertBitwise(expected.Normal, actual.Normal);
|
||||
AssertBitwise(expected.D, actual.D);
|
||||
}
|
||||
|
||||
private static void AssertBitwise(float expected, float actual)
|
||||
=> Assert.Equal(
|
||||
BitConverter.SingleToInt32Bits(expected),
|
||||
BitConverter.SingleToInt32Bits(actual));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue