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

309 lines
16 KiB
C#

using System.Collections;
using System.Collections.Immutable;
using System.Numerics;
using System.Reflection;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class CellTransitBuildingPartsTests
{
private const uint Prefix = 0xA9B40000u;
private const uint Outside = Prefix | 1u;
private const uint A = Prefix | 0x100u;
private const uint B = Prefix | 0x101u;
private const uint C = Prefix | 0x102u;
private const float Epsilon = 0.000199999995f;
private static readonly Vector3 Origin = new(12, 12, 0);
private static readonly Plane Permissive = new(Vector3.UnitZ, 100);
private static readonly PortalSpec DefaultPortal = new(0xFFFF, new Plane(Vector3.UnitX, 0), 2);
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CathedralLikeSphereHit_BoxBelowDestinationPlane_RejectsOnlyPartRoute(bool prepared)
{
PhysicsDataCache cache = Cache(prepared);
CellPhysics cell = Cell(A, prepared, new Plane(Vector3.UnitZ, -21.8948f), DefaultPortal);
cache.RegisterCellStructForTest(A, cell);
BuildingPhysics building = Building(new BldPortalInfo(A, 0, 0));
cache.RegisterBuildingForTest(Outside, building);
ShadowPartBox[] boxes = [Box(new(-1, -1, 16.86699f), new(1, 1, 21.894302f))];
Sphere[] spheres = [SphereAt(new(0, 0, 19.380646f), 3.93215f)];
var sphereCells = new CellArray();
CellTransit.CheckBuildingTransit(cache, building, spheres, 1, sphereCells, out bool hits);
Assert.True(hits);
Assert.Equal(new[] { A }, sphereCells.OrderedIds);
Assert.Equal(new[] { Outside }, Flood(cache, boxes, spheres));
if (prepared)
{
Assert.Null(cell.PortalPolygons);
Assert.Equal((ushort)42, cell.Portals[0].PolygonId);
Assert.Equal(1, cell.FlatTopology!.Portals[0].PolygonIndex);
Assert.Equal((ushort)7, cell.FlatPortalPolygons!.Polygons[0].Id);
}
}
[Theory]
[InlineData(false, -1, false)]
[InlineData(false, 0, true)]
[InlineData(false, 1, true)]
[InlineData(true, -1, true)]
[InlineData(true, 0, true)]
[InlineData(true, 1, false)]
public void ReciprocalSide_AdmitsOnlyCrossingOrSameSide(bool negativeSide, int boxSide, bool admitted)
{
PhysicsDataCache cache = Cache(true);
ushort flags = negativeSide ? (ushort)0 : (ushort)2;
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), flags)));
// Opposite building flags must not replace the destination's reciprocal side.
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, (ushort)(flags ^ 2))));
float center = boxSide * 0.5f;
ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, center - 0.1f), new(0.1f, 0.1f, center + 0.1f))];
IReadOnlyList<uint> result = Flood(cache, boxes, [SphereAt(Vector3.Zero, 1)]);
Assert.Equal(admitted ? new[] { Outside, A } : new[] { Outside }, result);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void BuildingCheapGate_EqualityAdmits_AdjacentOutsideFloatRejects(bool negativeSide, bool beyond)
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), negativeSide ? (ushort)0 : (ushort)2)));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
float padded = 0.5f + Epsilon;
float z = negativeSide ? padded : -padded;
if (beyond) z = negativeSide ? MathF.BitIncrement(z) : MathF.BitDecrement(z);
// Nearest point of the float-stored equality sphere. It is strictly inside
// the box epsilon band, isolating cheap equality from the open WhichSide +epsilon tie.
float boxZ = negativeSide ? padded - 0.5f : 0.5f - padded;
Assert.InRange(MathF.Abs(boxZ), 0f, MathF.BitDecrement(Epsilon));
ShadowPartBox[] boxes = [Box(new(0, 0, boxZ), new(0, 0, boxZ))];
Assert.Equal(beyond ? new[] { Outside } : new[] { Outside, A },
Flood(cache, boxes, [SphereAt(new(0, 0, z), 0.5f)]));
}
[Fact]
public void EarlierPartRejects_LaterPartAdmits_WithoutChangingTheirOrder()
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2)));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
ShadowPartBox[] boxes = [Box(new(-0.1f, -0.1f, -2), new(0.1f, 0.1f, -1)),
Box(new(-0.1f, -0.1f, 1), new(0.1f, 0.1f, 2))];
Sphere[] spheres = [SphereAt(Vector3.Zero, 3), SphereAt(Vector3.Zero, 3)];
Assert.Equal(new[] { Outside }, Flood(cache, boxes[..1], spheres[..1]));
Assert.Equal(new[] { Outside, A }, Flood(cache, boxes, spheres));
}
[Fact]
public void BuildingDestination_ExpandsBeforeNextAuthoredPortal()
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal,
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0)));
Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
}
[Fact]
public void ImmediateDestinationExpansion_ReceivesAllParts_NotJustTheBuildingHit()
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive,
new PortalSpec(0xFFFF, new Plane(Vector3.UnitZ, 0), 2),
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
cache.RegisterBuildingForTest(Outside, Building(new(A, 0, 0), new(B, 0, 0)));
ShadowPartBox[] boxes = [Box(new(-1, -0.1f, 0.1f), new(-0.5f, 0.1f, 0.2f)),
Box(new(0.5f, -0.1f, 0.1f), new(1, 0.1f, 0.2f))];
Assert.Equal(new[] { Outside, A, C, B }, Flood(cache, boxes,
[SphereAt(Vector3.Zero, 2), SphereAt(Vector3.Zero, 2)]));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DuplicateDestination_StillExpandsImmediately_WithSharedOutsideLatch(bool alreadyOutside)
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal,
new PortalSpec(0x102, new Plane(Vector3.UnitX, 0), 0)));
cache.RegisterCellStructForTest(B, Cell(B, true, Permissive, DefaultPortal));
cache.RegisterCellStructForTest(C, Cell(C, true, Permissive));
var candidates = new CellArray();
candidates.Add(A);
// This exact prefilled worklist cannot be supplied through the public entry.
MethodInfo helper = Assert.IsAssignableFrom<MethodInfo>(typeof(CellTransit).GetMethod(
"CheckBuildingTransitFromParts", BindingFlags.Static | BindingFlags.NonPublic));
object[] arguments = [cache, Building(new(A, 0, 0), new(B, 0, 0)),
new[] { SmallBox() }, new[] { SphereAt(Vector3.Zero, 1) }, candidates,
Outside, Vector3.Zero, alreadyOutside];
helper.Invoke(null, arguments);
Assert.True((bool)arguments[7]);
Assert.Equal(alreadyOutside ? new[] { A, C, B } : new[] { A, C, Outside, B }, candidates.OrderedIds);
}
[Fact]
public void FirstHitBreaksPartLoop_ImmediateAndOuterDestinationVisitsRemainDistinct()
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
var spheres = new ReadTrackingSpheres([SphereAt(Vector3.Zero, 1), SphereAt(Vector3.Zero, 1)]);
Assert.Equal(new[] { Outside, A }, Flood(cache, [SmallBox(), SmallBox()], spheres));
// Building hit, immediate destination portal, then growing-array destination portal.
Assert.Equal(new[] { 0, 0, 0 }, spheres.Reads);
}
[Theory]
[InlineData(-1)]
[InlineData(short.MinValue)]
public void NegativeReciprocal_IsSkipped(short reciprocal)
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0)));
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void UnavailableOrRootlessDestination_IsSkippedBeforeReciprocalValidation(bool rootless)
{
PhysicsDataCache cache = Cache(true);
if (rootless) cache.RegisterCellStructForTest(A, Cell(A, true, null, DefaultPortal));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 300, 0)));
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
}
[Theory]
[InlineData(1)]
[InlineData(300)]
public void MalformedPositiveReciprocal_ThrowsExplicitContentIntegrityFailure(short reciprocal)
{
PhysicsDataCache cache = Cache(true);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, reciprocal, 0)));
InvalidDataException failure = Assert.Throws<InvalidDataException>(() =>
Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
Assert.Equal($"Building portal to cell 0xA9B40100 references reciprocal portal {reciprocal}, but the destination has 1 portals.", failure.Message);
}
[Fact]
public void EmptyInputs_PreserveExistingSeedAndPartGuards()
{
PhysicsDataCache cache = Cache(true);
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
Assert.Empty(Flood(cache, [], []));
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], []));
Assert.Empty(CellTransit.BuildShadowCellSetFromParts(cache, 0, [SmallBox()], [SphereAt(Vector3.Zero, 1)], false));
}
[Fact]
public void UnloadedActiveOutdoorCell_PreservesOutsideIdsWithoutWalkingCachedBuilding()
{
PhysicsDataCache cache = Cache(true, terrain: false);
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
cache.RegisterBuildingForTest(Outside, Building(new BldPortalInfo(A, 0, 0)));
Assert.Equal(new[] { Outside }, Flood(cache, [SmallBox()], [SphereAt(Vector3.Zero, 1)]));
}
[Fact]
public void UnloadedLaterOutdoorCandidate_DoesNotWalkItsCachedBuilding()
{
PhysicsDataCache cache = Cache(true);
const uint adjacentOutside = 0xAAB40001u;
cache.RegisterCellStructForTest(A, Cell(A, true, Permissive, DefaultPortal));
cache.RegisterBuildingForTest(adjacentOutside, Building(new BldPortalInfo(A, 0, 0)));
ShadowPartBox[] boxes = [Box(new(-1, -1, -0.1f), new(1, 1, 0.1f), new Vector3(192, 12, 0))];
IReadOnlyList<uint> result = CellTransit.BuildShadowCellSetFromParts(
cache, Prefix | 57u, boxes, [SphereAt(new(180, 0, 0), 2)], false);
Assert.Equal(new[] { Prefix | 57u, adjacentOutside }, result);
}
private static PhysicsDataCache Cache(bool prepared, bool terrain = true)
{
PhysicsDataCache cache = prepared ? PhysicsDataCache.CreateProduction() : new PhysicsDataCache();
if (terrain) cache.CellGraph.RegisterTerrain(Prefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero);
return cache;
}
private static IReadOnlyList<uint> Flood(PhysicsDataCache cache, IReadOnlyList<ShadowPartBox> boxes, IReadOnlyList<Sphere> spheres) =>
CellTransit.BuildShadowCellSetFromParts(cache, Outside, boxes, spheres, isStatic: true);
private static BuildingPhysics Building(params BldPortalInfo[] portals) => new()
{
WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = portals,
};
private readonly record struct PortalSpec(ushort Other, Plane Plane, ushort Flags);
private static CellPhysics Cell(uint id, bool prepared, Plane? containment, params PortalSpec[] specs)
{
CellBSPNode? root = containment is { } plane ? new CellBSPNode
{
Type = BSPNodeType.BPOL, SplittingPlane = plane, PosNode = null, NegNode = null,
} : null;
// Polygon ID 42 is deliberately at flat table index1, not index0 or42.
var polygons = new Dictionary<ushort, ResolvedPolygon>
{
[7] = new() { Id = 7, Plane = new Plane(Vector3.UnitZ, -1000), Vertices = [], NumPoints = 0, SidesType = CullMode.None },
};
var portals = new List<PortalInfo>();
var flatPortals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>();
for (int i = 0; i < specs.Length; i++)
{
ushort polygonId = checked((ushort)(42 + i));
PortalSpec spec = specs[i];
polygons.Add(polygonId, new ResolvedPolygon { Id = polygonId, Plane = spec.Plane, Vertices = [], NumPoints = 0, SidesType = CullMode.None });
portals.Add(new PortalInfo(spec.Other, polygonId, spec.Flags));
flatPortals.Add(new FlatEnvCellPortal(spec.Other, polygonId, spec.Flags, i + 1));
}
return new CellPhysics
{
SourceId = id, WorldTransform = Matrix4x4.CreateTranslation(Origin),
InverseWorldTransform = Matrix4x4.CreateTranslation(-Origin), Resolved = [],
CellBSP = prepared ? null : new CellBSPTree { Root = root },
FlatContainmentBsp = FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root),
Portals = portals, PortalPolygons = prepared ? null : polygons,
FlatPortalPolygons = new FlatPolygonTable(polygons.Values.OrderBy(p => p.Id)
.Select(p => new FlatCollisionPolygon(p.Id, p.Plane, p.SidesType, 0, new FlatIndexRange(0, 0))).ToImmutableArray(), []),
FlatTopology = new FlatEnvCellTopology(flatPortals.ToImmutable(), [], true),
};
}
private static Sphere SphereAt(Vector3 localCenter, float radius) => new() { Origin = Origin + localCenter, Radius = radius };
private static ShadowPartBox SmallBox() => Box(new(-0.1f), new(0.1f));
private static ShadowPartBox Box(Vector3 min, Vector3 max, Vector3? worldPosition = null)
{
var geometry = ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 1),
new FlatGfxObjVisualBounds(min, max, (min + max) * 0.5f, ((max - min) * 0.5f).Length(), (max - min) * 0.5f));
ShadowShape shape = ShadowShape.Bsp(0x01000001, Vector3.Zero, Quaternion.Identity, 1, geometry);
return ShadowPartBox.FromShape(shape, worldPosition ?? Origin, Quaternion.Identity);
}
private sealed class ReadTrackingSpheres(Sphere[] values) : IReadOnlyList<Sphere>
{
public List<int> Reads { get; } = [];
public int Count => values.Length;
public Sphere this[int index] { get { Reads.Add(index); return values[index]; } }
public IEnumerator<Sphere> GetEnumerator() => ((IEnumerable<Sphere>)values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}