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:
parent
16d182c2f0
commit
d9300c7854
11 changed files with 2094 additions and 21 deletions
483
src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
Normal file
483
src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
565
src/AcDream.Core/Physics/FlatCollisionAssets.cs
Normal file
565
src/AcDream.Core/Physics/FlatCollisionAssets.cs
Normal 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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue