feat(physics): define deterministic flat collision assets

Add immutable indexed physics and containment BSP records, exact-bit polygon and Setup payloads, separated CellStruct/topology ownership, and iterative positive-before-negative flattening. Reject malformed graphs and ranges, and prove source identity over synthetic edge cases and installed retail DAT samples without cutting production traversal over.
This commit is contained in:
Erik 2026-07-25 14:52:47 +02:00
parent 16d182c2f0
commit d9300c7854
11 changed files with 2094 additions and 21 deletions

View file

@ -0,0 +1,483 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Runtime.CompilerServices;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
/// <summary>
/// Deterministic preparation from the current parsed-DAT graph oracle into
/// immutable flat collision records. This class does not perform traversal.
/// Source field and ordering contracts are pinned from retail
/// <c>BSPTREE::find_collisions</c> (0x0053A440),
/// <c>BSPNODE::find_walkable</c> (0x0053CC80), and
/// <c>BSPLEAF::find_walkable</c> (0x0053D6F0).
/// </summary>
public static class FlatCollisionAssetBuilder
{
public static FlatPhysicsBsp FlattenPhysicsBsp(
PhysicsBSPNode? root,
IReadOnlyDictionary<ushort, ResolvedPolygon> resolved)
{
ArgumentNullException.ThrowIfNull(resolved);
FlatPolygonTable polygonTable = FlattenPolygonTable(resolved);
if (root is null)
{
return new FlatPhysicsBsp(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
polygonTable);
}
var nodes = new List<MutablePhysicsNode>();
var polygonIndices = new List<int>();
var visited = new Dictionary<PhysicsBSPNode, int>(
ReferenceEqualityComparer<PhysicsBSPNode>.Instance);
var stack = new Stack<PhysicsNodeFrame>();
stack.Push(new PhysicsNodeFrame(root, -1, ChildEdge.Root));
while (stack.Count != 0)
{
PhysicsNodeFrame frame = stack.Pop();
FlatBspNodeContract.Validate(
frame.Node.Type,
frame.Node.PosNode is not null,
frame.Node.NegNode is not null,
"physics BSP source node");
if (visited.ContainsKey(frame.Node))
{
throw new InvalidDataException(
"Physics BSP source contains a cycle or shared child.");
}
int nodeIndex = nodes.Count;
visited.Add(frame.Node, nodeIndex);
AttachChild(nodes, frame.ParentIndex, frame.Edge, nodeIndex);
int polygonStart = polygonIndices.Count;
if (frame.Node.Polygons is null)
{
throw new InvalidDataException(
$"Physics BSP node {nodeIndex} has no polygon-reference list.");
}
foreach (ushort polygonId in frame.Node.Polygons)
{
if (!polygonTable.TryFindPolygonIndex(polygonId, out int polygonIndex))
{
throw new InvalidDataException(
$"Physics BSP leaf references missing polygon 0x{polygonId:X4}.");
}
polygonIndices.Add(polygonIndex);
}
Sphere boundingSphere = frame.Node.BoundingSphere
?? throw new InvalidDataException(
$"Physics BSP node {nodeIndex} has no bounding sphere.");
nodes.Add(new MutablePhysicsNode(
frame.Node.Type,
frame.Node.SplittingPlane,
frame.Node.LeafIndex,
frame.Node.Solid,
new FlatCollisionSphere(
boundingSphere.Origin,
boundingSphere.Radius),
new FlatIndexRange(
polygonStart,
polygonIndices.Count - polygonStart)));
// Stack order is reversed so positive is emitted before negative.
if (frame.Node.NegNode is not null)
{
stack.Push(new PhysicsNodeFrame(
frame.Node.NegNode,
nodeIndex,
ChildEdge.Negative));
}
if (frame.Node.PosNode is not null)
{
stack.Push(new PhysicsNodeFrame(
frame.Node.PosNode,
nodeIndex,
ChildEdge.Positive));
}
}
var immutableNodes = ImmutableArray.CreateBuilder<FlatPhysicsBspNode>(nodes.Count);
foreach (MutablePhysicsNode node in nodes)
immutableNodes.Add(node.Freeze());
return new FlatPhysicsBsp(
0,
immutableNodes.MoveToImmutable(),
ImmutableArray.CreateRange(polygonIndices),
polygonTable);
}
public static FlatCellContainmentBsp FlattenCellContainmentBsp(
CellBSPNode? root)
{
if (root is null)
{
return new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty);
}
var nodes = new List<MutableCellNode>();
var visited = new Dictionary<CellBSPNode, int>(
ReferenceEqualityComparer<CellBSPNode>.Instance);
var stack = new Stack<CellNodeFrame>();
stack.Push(new CellNodeFrame(root, -1, ChildEdge.Root));
while (stack.Count != 0)
{
CellNodeFrame frame = stack.Pop();
FlatBspNodeContract.Validate(
frame.Node.Type,
frame.Node.PosNode is not null,
frame.Node.NegNode is not null,
"cell-containment BSP source node");
if (visited.ContainsKey(frame.Node))
{
throw new InvalidDataException(
"Cell-containment BSP source contains a cycle or shared child.");
}
int nodeIndex = nodes.Count;
visited.Add(frame.Node, nodeIndex);
AttachChild(nodes, frame.ParentIndex, frame.Edge, nodeIndex);
nodes.Add(new MutableCellNode(
frame.Node.Type,
frame.Node.SplittingPlane,
frame.Node.LeafIndex));
if (frame.Node.NegNode is not null)
{
stack.Push(new CellNodeFrame(
frame.Node.NegNode,
nodeIndex,
ChildEdge.Negative));
}
if (frame.Node.PosNode is not null)
{
stack.Push(new CellNodeFrame(
frame.Node.PosNode,
nodeIndex,
ChildEdge.Positive));
}
}
var immutableNodes = ImmutableArray.CreateBuilder<FlatCellBspNode>(nodes.Count);
foreach (MutableCellNode node in nodes)
immutableNodes.Add(node.Freeze());
return new FlatCellContainmentBsp(0, immutableNodes.MoveToImmutable());
}
public static FlatPolygonTable FlattenPolygonTable(
IReadOnlyDictionary<ushort, ResolvedPolygon> resolved)
{
ArgumentNullException.ThrowIfNull(resolved);
var ordered = new List<KeyValuePair<ushort, ResolvedPolygon>>(resolved);
ordered.Sort(static (left, right) => left.Key.CompareTo(right.Key));
int vertexCount = 0;
foreach ((ushort id, ResolvedPolygon source) in ordered)
{
ValidateSourcePolygon(id, source);
vertexCount = checked(vertexCount + source.Vertices.Length);
}
var polygons = ImmutableArray.CreateBuilder<FlatCollisionPolygon>(ordered.Count);
var vertices = ImmutableArray.CreateBuilder<Vector3>(vertexCount);
foreach ((ushort id, ResolvedPolygon source) in ordered)
{
int vertexStart = vertices.Count;
foreach (Vector3 vertex in source.Vertices)
vertices.Add(vertex);
polygons.Add(new FlatCollisionPolygon(
id,
source.Plane,
source.SidesType,
source.NumPoints,
new FlatIndexRange(
vertexStart,
vertices.Count - vertexStart)));
}
return new FlatPolygonTable(
polygons.MoveToImmutable(),
vertices.MoveToImmutable());
}
private static void ValidateSourcePolygon(
ushort id,
ResolvedPolygon source)
{
if (source is null)
throw new InvalidDataException($"Polygon 0x{id:X4} is null.");
if (source.Id != id)
{
throw new InvalidDataException(
$"Polygon dictionary key 0x{id:X4} does not match row ID " +
$"0x{source.Id:X4}.");
}
if (source.Vertices is null)
throw new InvalidDataException($"Polygon 0x{id:X4} has no vertices.");
if (source.NumPoints != source.Vertices.Length)
{
throw new InvalidDataException(
$"Polygon 0x{id:X4} NumPoints {source.NumPoints} does not " +
$"match vertex count {source.Vertices.Length}.");
}
}
public static FlatSetupCollision FlattenSetup(SetupPhysics source)
{
ArgumentNullException.ThrowIfNull(source);
var cylinders =
ImmutableArray.CreateBuilder<FlatCollisionCylinder>(source.CylSpheres.Count);
foreach (CylSphere cylinder in source.CylSpheres)
{
if (cylinder is null)
throw new InvalidDataException("Setup cylinder row is null.");
cylinders.Add(new FlatCollisionCylinder(
cylinder.Origin,
cylinder.Radius,
cylinder.Height));
}
var spheres =
ImmutableArray.CreateBuilder<FlatCollisionSphere>(source.Spheres.Count);
foreach (Sphere sphere in source.Spheres)
{
if (sphere is null)
throw new InvalidDataException("Setup sphere row is null.");
spheres.Add(new FlatCollisionSphere(sphere.Origin, sphere.Radius));
}
return new FlatSetupCollision(
cylinders.MoveToImmutable(),
spheres.MoveToImmutable(),
source.Height,
source.Radius,
source.StepUpHeight,
source.StepDownHeight);
}
public static FlatGfxObjCollisionAsset FlattenGfxObj(
GfxObjPhysics source,
GfxObjVisualBounds? visualBounds = null)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(source.BSP);
FlatCollisionSphere? boundingSphere = source.BoundingSphere is null
? null
: new FlatCollisionSphere(
source.BoundingSphere.Origin,
source.BoundingSphere.Radius);
FlatGfxObjVisualBounds? flatVisualBounds = visualBounds is null
? null
: new FlatGfxObjVisualBounds(
visualBounds.Min,
visualBounds.Max,
visualBounds.Center,
visualBounds.Radius,
visualBounds.HalfExtents);
return new FlatGfxObjCollisionAsset(
FlattenPhysicsBsp(source.BSP.Root, source.Resolved),
boundingSphere,
flatVisualBounds);
}
public static FlatCellCollisionAsset FlattenCell(CellPhysics source)
{
ArgumentNullException.ThrowIfNull(source);
FlatPhysicsBsp physicsBsp = FlattenPhysicsBsp(
source.BSP?.Root,
source.Resolved);
FlatCellContainmentBsp containmentBsp =
FlattenCellContainmentBsp(source.CellBSP?.Root);
FlatPolygonTable portalPolygons = FlattenPolygonTable(
source.PortalPolygons
?? new Dictionary<ushort, ResolvedPolygon>());
var portals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
source.Portals.Count);
foreach (PortalInfo portal in source.Portals)
{
if (!portalPolygons.TryFindPolygonIndex(
portal.PolygonId,
out int polygonIndex))
{
throw new InvalidDataException(
$"EnvCell portal references missing polygon " +
$"0x{portal.PolygonId:X4}.");
}
portals.Add(new FlatEnvCellPortal(
portal.OtherCellId,
portal.PolygonId,
portal.Flags,
polygonIndex));
}
uint[] visibleIds = source.VisibleCellIds.ToArray();
Array.Sort(visibleIds);
var topology = new FlatEnvCellTopology(
portals.MoveToImmutable(),
ImmutableArray.Create(visibleIds),
source.SeenOutside);
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
containmentBsp,
portalPolygons);
return new FlatCellCollisionAsset(structure, topology);
}
private static void AttachChild(
List<MutablePhysicsNode> nodes,
int parentIndex,
ChildEdge edge,
int childIndex)
{
if (edge == ChildEdge.Root)
return;
if ((uint)parentIndex >= (uint)nodes.Count)
throw new InvalidDataException("Physics BSP child has no valid parent.");
MutablePhysicsNode parent = nodes[parentIndex];
if (edge == ChildEdge.Positive)
parent.PositiveChildIndex = childIndex;
else
parent.NegativeChildIndex = childIndex;
}
private static void AttachChild(
List<MutableCellNode> nodes,
int parentIndex,
ChildEdge edge,
int childIndex)
{
if (edge == ChildEdge.Root)
return;
if ((uint)parentIndex >= (uint)nodes.Count)
throw new InvalidDataException("Cell BSP child has no valid parent.");
MutableCellNode parent = nodes[parentIndex];
if (edge == ChildEdge.Positive)
parent.PositiveChildIndex = childIndex;
else
parent.NegativeChildIndex = childIndex;
}
private enum ChildEdge : byte
{
Root,
Positive,
Negative,
}
private readonly record struct PhysicsNodeFrame(
PhysicsBSPNode Node,
int ParentIndex,
ChildEdge Edge);
private readonly record struct CellNodeFrame(
CellBSPNode Node,
int ParentIndex,
ChildEdge Edge);
private sealed class MutablePhysicsNode
{
public MutablePhysicsNode(
DatReaderWriter.Enums.BSPNodeType type,
Plane splittingPlane,
int leafIndex,
int solid,
FlatCollisionSphere boundingSphere,
FlatIndexRange polygonIndexRange)
{
Type = type;
SplittingPlane = splittingPlane;
LeafIndex = leafIndex;
Solid = solid;
BoundingSphere = boundingSphere;
PolygonIndexRange = polygonIndexRange;
}
public DatReaderWriter.Enums.BSPNodeType Type { get; }
public Plane SplittingPlane { get; }
public int PositiveChildIndex { get; set; } = -1;
public int NegativeChildIndex { get; set; } = -1;
public int LeafIndex { get; }
public int Solid { get; }
public FlatCollisionSphere BoundingSphere { get; }
public FlatIndexRange PolygonIndexRange { get; }
public FlatPhysicsBspNode Freeze() => new(
Type,
SplittingPlane,
PositiveChildIndex,
NegativeChildIndex,
LeafIndex,
Solid,
BoundingSphere,
PolygonIndexRange);
}
private sealed class MutableCellNode
{
public MutableCellNode(
DatReaderWriter.Enums.BSPNodeType type,
Plane splittingPlane,
int leafIndex)
{
Type = type;
SplittingPlane = splittingPlane;
LeafIndex = leafIndex;
}
public DatReaderWriter.Enums.BSPNodeType Type { get; }
public Plane SplittingPlane { get; }
public int PositiveChildIndex { get; set; } = -1;
public int NegativeChildIndex { get; set; } = -1;
public int LeafIndex { get; }
public FlatCellBspNode Freeze() => new(
Type,
SplittingPlane,
PositiveChildIndex,
NegativeChildIndex,
LeafIndex);
}
private sealed class ReferenceEqualityComparer<T> : IEqualityComparer<T>
where T : class
{
public static ReferenceEqualityComparer<T> Instance { get; } = new();
public bool Equals(T? left, T? right) => ReferenceEquals(left, right);
public int GetHashCode(T value) => RuntimeHelpers.GetHashCode(value);
}
}

View file

@ -0,0 +1,565 @@
using System.Collections.Immutable;
using System.Numerics;
using DatReaderWriter.Enums;
namespace AcDream.Core.Physics;
/// <summary>
/// A checked half-open range into one immutable flat-asset array.
/// </summary>
public readonly record struct FlatIndexRange(int Start, int Count)
{
public int EndExclusive => checked(Start + Count);
internal void Validate(int length, string name)
{
if (Start < 0 || Count < 0 || Start > length - Count)
{
throw new InvalidDataException(
$"{name} range [{Start}, {Start}+{Count}) exceeds array length {length}.");
}
}
}
/// <summary>
/// Verbatim DAT sphere value. No normalization or coordinate conversion is
/// performed while preparing this record.
/// </summary>
public readonly record struct FlatCollisionSphere(Vector3 Origin, float Radius);
/// <summary>
/// Verbatim DAT cylinder-sphere value.
/// </summary>
public readonly record struct FlatCollisionCylinder(
Vector3 Origin,
float Radius,
float Height);
/// <summary>
/// One polygon row addressed through <see cref="VertexRange"/>.
/// </summary>
public readonly record struct FlatCollisionPolygon(
ushort Id,
Plane Plane,
CullMode SidesType,
int NumPoints,
FlatIndexRange VertexRange);
/// <summary>
/// Immutable polygon rows plus their contiguous, source-ordered vertex stream.
/// Rows are strictly ordered by polygon ID so package output does not depend on
/// dictionary insertion or bucket order.
/// </summary>
public sealed class FlatPolygonTable
{
public static FlatPolygonTable Empty { get; } = new(
ImmutableArray<FlatCollisionPolygon>.Empty,
ImmutableArray<Vector3>.Empty);
public FlatPolygonTable(
ImmutableArray<FlatCollisionPolygon> polygons,
ImmutableArray<Vector3> vertices)
{
if (polygons.IsDefault)
throw new ArgumentException("Polygon rows must not be default.", nameof(polygons));
if (vertices.IsDefault)
throw new ArgumentException("Vertex rows must not be default.", nameof(vertices));
ushort previousId = 0;
for (int i = 0; i < polygons.Length; i++)
{
FlatCollisionPolygon polygon = polygons[i];
polygon.VertexRange.Validate(vertices.Length, $"polygon[{i}].vertices");
if (polygon.NumPoints != polygon.VertexRange.Count)
{
throw new InvalidDataException(
$"polygon[{i}] NumPoints {polygon.NumPoints} does not match " +
$"its vertex count {polygon.VertexRange.Count}.");
}
if (!Enum.IsDefined(polygon.SidesType))
{
throw new InvalidDataException(
$"polygon[{i}] has unknown cull mode {(int)polygon.SidesType}.");
}
if (i != 0 && polygon.Id <= previousId)
{
throw new InvalidDataException(
"Flat polygon rows must be strictly ordered by unique ID.");
}
previousId = polygon.Id;
}
Polygons = polygons;
Vertices = vertices;
}
public ImmutableArray<FlatCollisionPolygon> Polygons { get; }
public ImmutableArray<Vector3> Vertices { get; }
public bool TryFindPolygonIndex(ushort id, out int index)
{
int low = 0;
int high = Polygons.Length - 1;
while (low <= high)
{
int middle = low + ((high - low) >> 1);
ushort candidate = Polygons[middle].Id;
if (candidate == id)
{
index = middle;
return true;
}
if (candidate < id)
low = middle + 1;
else
high = middle - 1;
}
index = -1;
return false;
}
}
/// <summary>
/// One physics BSP node. Child index <c>-1</c> means absent. Polygon indices
/// address <see cref="FlatPhysicsBsp.PolygonIndexStream"/> through
/// <see cref="PolygonIndexRange"/>.
/// </summary>
public readonly record struct FlatPhysicsBspNode(
BSPNodeType Type,
Plane SplittingPlane,
int PositiveChildIndex,
int NegativeChildIndex,
int LeafIndex,
int Solid,
FlatCollisionSphere BoundingSphere,
FlatIndexRange PolygonIndexRange);
/// <summary>
/// Immutable, deterministic pre-order representation of a retail physics BSP.
/// It changes source storage only; traversal order and behavior remain defined
/// by the graph oracle until Slice I6.
/// </summary>
public sealed class FlatPhysicsBsp
{
public FlatPhysicsBsp(
int rootIndex,
ImmutableArray<FlatPhysicsBspNode> nodes,
ImmutableArray<int> polygonIndexStream,
FlatPolygonTable polygonTable)
{
if (nodes.IsDefault)
throw new ArgumentException("Node rows must not be default.", nameof(nodes));
if (polygonIndexStream.IsDefault)
{
throw new ArgumentException(
"Polygon-index rows must not be default.",
nameof(polygonIndexStream));
}
ArgumentNullException.ThrowIfNull(polygonTable);
ValidateTree(rootIndex, nodes, polygonIndexStream, polygonTable.Polygons.Length);
RootIndex = rootIndex;
Nodes = nodes;
PolygonIndexStream = polygonIndexStream;
PolygonTable = polygonTable;
}
public int RootIndex { get; }
public ImmutableArray<FlatPhysicsBspNode> Nodes { get; }
public ImmutableArray<int> PolygonIndexStream { get; }
public FlatPolygonTable PolygonTable { get; }
private static void ValidateTree(
int rootIndex,
ImmutableArray<FlatPhysicsBspNode> nodes,
ImmutableArray<int> polygonIndexStream,
int polygonCount)
{
if (nodes.Length == 0)
{
if (rootIndex != -1)
throw new InvalidDataException("An empty physics BSP must use root index -1.");
if (polygonIndexStream.Length != 0)
{
throw new InvalidDataException(
"An empty physics BSP cannot contain leaf polygon indices.");
}
return;
}
if (rootIndex != 0)
{
throw new InvalidDataException(
$"A deterministic pre-order physics BSP must start at index 0, not {rootIndex}.");
}
for (int i = 0; i < nodes.Length; i++)
{
FlatPhysicsBspNode node = nodes[i];
FlatBspNodeContract.Validate(
node.Type,
node.PositiveChildIndex >= 0,
node.NegativeChildIndex >= 0,
$"physics node[{i}]");
ValidateChild(node.PositiveChildIndex, nodes.Length, i, "positive");
ValidateChild(node.NegativeChildIndex, nodes.Length, i, "negative");
node.PolygonIndexRange.Validate(
polygonIndexStream.Length,
$"physics node[{i}].polygonIndices");
}
for (int i = 0; i < polygonIndexStream.Length; i++)
{
int polygonIndex = polygonIndexStream[i];
if ((uint)polygonIndex >= (uint)polygonCount)
{
throw new InvalidDataException(
$"leaf polygon stream[{i}] index {polygonIndex} exceeds " +
$"polygon count {polygonCount}.");
}
}
ValidatePreOrderShape(
rootIndex,
nodes.Length,
static (rows, index) => rows[index].PositiveChildIndex,
static (rows, index) => rows[index].NegativeChildIndex,
nodes,
"physics BSP");
}
private static void ValidateChild(
int childIndex,
int nodeCount,
int ownerIndex,
string edge)
{
if (childIndex < -1 || childIndex >= nodeCount)
{
throw new InvalidDataException(
$"physics node[{ownerIndex}] {edge} child {childIndex} is out of range.");
}
}
internal static void ValidatePreOrderShape<TNode>(
int rootIndex,
int nodeCount,
Func<ImmutableArray<TNode>, int, int> getPositive,
Func<ImmutableArray<TNode>, int, int> getNegative,
ImmutableArray<TNode> nodes,
string name)
{
var visited = new bool[nodeCount];
var stack = new Stack<int>();
stack.Push(rootIndex);
int expectedIndex = 0;
while (stack.Count != 0)
{
int index = stack.Pop();
if (visited[index])
throw new InvalidDataException($"{name} contains a cycle or shared child.");
if (index != expectedIndex)
{
throw new InvalidDataException(
$"{name} is not in deterministic positive-before-negative pre-order: " +
$"expected node {expectedIndex}, encountered {index}.");
}
visited[index] = true;
expectedIndex++;
int negative = getNegative(nodes, index);
int positive = getPositive(nodes, index);
if (negative >= 0)
stack.Push(negative);
if (positive >= 0)
stack.Push(positive);
}
if (expectedIndex != nodeCount)
{
throw new InvalidDataException(
$"{name} contains {nodeCount - expectedIndex} unreachable node(s).");
}
}
}
/// <summary>
/// One node in the separate cell-containment BSP.
/// </summary>
public readonly record struct FlatCellBspNode(
BSPNodeType Type,
Plane SplittingPlane,
int PositiveChildIndex,
int NegativeChildIndex,
int LeafIndex);
/// <summary>
/// Immutable deterministic representation of a cell-containment BSP. It is
/// intentionally separate from physics BSP storage because it has no leaf
/// polygons, solid marker, or bounding sphere.
/// </summary>
public sealed class FlatCellContainmentBsp
{
public FlatCellContainmentBsp(
int rootIndex,
ImmutableArray<FlatCellBspNode> nodes)
{
if (nodes.IsDefault)
throw new ArgumentException("Node rows must not be default.", nameof(nodes));
if (nodes.Length == 0)
{
if (rootIndex != -1)
throw new InvalidDataException("An empty cell BSP must use root index -1.");
}
else
{
if (rootIndex != 0)
{
throw new InvalidDataException(
$"A deterministic pre-order cell BSP must start at index 0, not {rootIndex}.");
}
for (int i = 0; i < nodes.Length; i++)
{
FlatCellBspNode node = nodes[i];
FlatBspNodeContract.Validate(
node.Type,
node.PositiveChildIndex >= 0,
node.NegativeChildIndex >= 0,
$"cell node[{i}]");
if (node.PositiveChildIndex < -1 || node.PositiveChildIndex >= nodes.Length)
{
throw new InvalidDataException(
$"cell node[{i}] positive child {node.PositiveChildIndex} is out of range.");
}
if (node.NegativeChildIndex < -1 || node.NegativeChildIndex >= nodes.Length)
{
throw new InvalidDataException(
$"cell node[{i}] negative child {node.NegativeChildIndex} is out of range.");
}
}
FlatPhysicsBsp.ValidatePreOrderShape(
rootIndex,
nodes.Length,
static (rows, index) => rows[index].PositiveChildIndex,
static (rows, index) => rows[index].NegativeChildIndex,
nodes,
"cell-containment BSP");
}
RootIndex = rootIndex;
Nodes = nodes;
}
public int RootIndex { get; }
public ImmutableArray<FlatCellBspNode> Nodes { get; }
}
/// <summary>
/// Immutable Setup collision payload. List order is the DAT order.
/// </summary>
public sealed class FlatSetupCollision
{
public FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder> cylinders,
ImmutableArray<FlatCollisionSphere> spheres,
float height,
float radius,
float stepUpHeight,
float stepDownHeight)
{
if (cylinders.IsDefault)
throw new ArgumentException("Cylinder rows must not be default.", nameof(cylinders));
if (spheres.IsDefault)
throw new ArgumentException("Sphere rows must not be default.", nameof(spheres));
Cylinders = cylinders;
Spheres = spheres;
Height = height;
Radius = radius;
StepUpHeight = stepUpHeight;
StepDownHeight = stepDownHeight;
}
public ImmutableArray<FlatCollisionCylinder> Cylinders { get; }
public ImmutableArray<FlatCollisionSphere> Spheres { get; }
public float Height { get; }
public float Radius { get; }
public float StepUpHeight { get; }
public float StepDownHeight { get; }
}
public readonly record struct FlatGfxObjVisualBounds(
Vector3 Min,
Vector3 Max,
Vector3 Center,
float Radius,
Vector3 HalfExtents);
/// <summary>
/// Immutable GfxObj collision payload prepared independently of rendering.
/// </summary>
public sealed record FlatGfxObjCollisionAsset(
FlatPhysicsBsp PhysicsBsp,
FlatCollisionSphere? BoundingSphere,
FlatGfxObjVisualBounds? VisualBounds);
/// <summary>
/// Immutable CellStruct geometry. Per-EnvCell transforms and topology are not
/// embedded so identical CellStruct geometry can be aliased safely.
/// </summary>
public sealed record FlatCellStructureCollisionAsset(
FlatPhysicsBsp PhysicsBsp,
FlatCellContainmentBsp ContainmentBsp,
FlatPolygonTable PortalPolygons);
/// <summary>
/// One per-EnvCell portal row. <see cref="PolygonIndex"/> directly addresses
/// the owning <see cref="FlatCellStructureCollisionAsset.PortalPolygons"/>.
/// </summary>
public readonly record struct FlatEnvCellPortal(
ushort OtherCellId,
ushort PolygonId,
ushort Flags,
int PolygonIndex);
/// <summary>
/// Per-EnvCell topology kept separate from reusable CellStruct geometry.
/// Visible cell IDs are sorted to make prepared output deterministic.
/// </summary>
public sealed class FlatEnvCellTopology
{
public FlatEnvCellTopology(
ImmutableArray<FlatEnvCellPortal> portals,
ImmutableArray<uint> visibleCellIds,
bool seenOutside)
{
if (portals.IsDefault)
throw new ArgumentException("Portal rows must not be default.", nameof(portals));
if (visibleCellIds.IsDefault)
{
throw new ArgumentException(
"Visible-cell rows must not be default.",
nameof(visibleCellIds));
}
uint previous = 0;
for (int i = 0; i < visibleCellIds.Length; i++)
{
uint current = visibleCellIds[i];
if (i != 0 && current <= previous)
{
throw new InvalidDataException(
"Visible cell IDs must be strictly ordered and unique.");
}
previous = current;
}
for (int i = 0; i < portals.Length; i++)
{
if (portals[i].PolygonIndex < 0)
{
throw new InvalidDataException(
$"portal[{i}] has invalid polygon index {portals[i].PolygonIndex}.");
}
}
Portals = portals;
VisibleCellIds = visibleCellIds;
SeenOutside = seenOutside;
}
public ImmutableArray<FlatEnvCellPortal> Portals { get; }
public ImmutableArray<uint> VisibleCellIds { get; }
public bool SeenOutside { get; }
}
/// <summary>
/// Test/shadow aggregate joining one reusable CellStruct with one EnvCell's
/// topology. Production package/publication layers keep these lifetimes
/// separate.
/// </summary>
public sealed class FlatCellCollisionAsset
{
public FlatCellCollisionAsset(
FlatCellStructureCollisionAsset structure,
FlatEnvCellTopology topology)
{
ArgumentNullException.ThrowIfNull(structure);
ArgumentNullException.ThrowIfNull(topology);
int polygonCount = structure.PortalPolygons.Polygons.Length;
for (int i = 0; i < topology.Portals.Length; i++)
{
int polygonIndex = topology.Portals[i].PolygonIndex;
if ((uint)polygonIndex >= (uint)polygonCount)
{
throw new InvalidDataException(
$"portal[{i}] polygon index {polygonIndex} exceeds " +
$"portal polygon count {polygonCount}.");
}
}
Structure = structure;
Topology = topology;
}
public FlatCellStructureCollisionAsset Structure { get; }
public FlatEnvCellTopology Topology { get; }
}
internal static class FlatBspNodeContract
{
public static void Validate(
BSPNodeType type,
bool hasPositive,
bool hasNegative,
string name)
{
if (!Enum.IsDefined(type) || type == BSPNodeType.Portal)
throw new InvalidDataException($"{name} has unsupported type 0x{(uint)type:X8}.");
(bool expectedPositive, bool expectedNegative) = type switch
{
BSPNodeType.Leaf => (false, false),
BSPNodeType.BPIn or BSPNodeType.BPnn => (true, false),
BSPNodeType.BpIN or BSPNodeType.BpnN => (false, true),
BSPNodeType.BPIN or BSPNodeType.BPnN => (true, true),
BSPNodeType.BPOL or BSPNodeType.BPFL => (false, false),
_ => throw new InvalidDataException(
$"{name} has unsupported type 0x{(uint)type:X8}."),
};
if (hasPositive != expectedPositive || hasNegative != expectedNegative)
{
throw new InvalidDataException(
$"{name} type {type} requires children " +
$"(+={expectedPositive}, -={expectedNegative}) but contains " +
$"(+={hasPositive}, -={hasNegative}).");
}
}
}