acdream/src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
Erik 9cd42417a8 feat(content): bake and read flat collision assets
Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
2026-07-25 15:22:08 +02:00

691 lines
23 KiB
C#

using System.Collections.Immutable;
using System.Numerics;
using System.Runtime.CompilerServices;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
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
{
private static FlatPhysicsBsp EmptyPhysicsBsp() =>
new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
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);
}
/// <summary>
/// Prepares the exact Setup collision fields used by
/// <see cref="PhysicsDataCache.CacheSetup"/> without retaining the parsed
/// DAT object. Source list order and every float bit are preserved.
/// </summary>
public static FlatSetupCollision FlattenSetup(Setup source)
{
ArgumentNullException.ThrowIfNull(source);
var cylinders = ImmutableArray.CreateBuilder<FlatCollisionCylinder>(
source.CylSpheres?.Count ?? 0);
if (source.CylSpheres is not null)
{
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 ?? 0);
if (source.Spheres is not null)
{
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);
}
/// <summary>
/// Prepares one parsed GfxObj and drops the source graph. Objects without
/// a retail physics BSP still retain their visual bounds because the
/// current runtime uses those bounds as its collision fallback.
/// </summary>
public static FlatGfxObjCollisionAsset FlattenGfxObj(GfxObj source)
{
ArgumentNullException.ThrowIfNull(source);
GfxObjVisualBounds? visualBounds = source.VertexArray is null
? null
: PhysicsDataCache.ComputeVisualBounds(source.VertexArray);
FlatGfxObjVisualBounds? flatVisualBounds = visualBounds is null
? null
: new FlatGfxObjVisualBounds(
visualBounds.Min,
visualBounds.Max,
visualBounds.Center,
visualBounds.Radius,
visualBounds.HalfExtents);
bool hasPhysics =
source.Flags.HasFlag(GfxObjFlags.HasPhysics)
&& source.PhysicsBSP?.Root is not null
&& source.VertexArray is not null;
if (!hasPhysics)
{
return new FlatGfxObjCollisionAsset(
EmptyPhysicsBsp(),
null,
flatVisualBounds);
}
Dictionary<ushort, ResolvedPolygon> resolved =
PhysicsDataCache.ResolvePolygons(
source.PhysicsPolygons,
source.VertexArray!);
Sphere boundingSphere = source.PhysicsBSP!.Root!.BoundingSphere
?? throw new InvalidDataException(
"GfxObj physics BSP root has no bounding sphere.");
return new FlatGfxObjCollisionAsset(
FlattenPhysicsBsp(source.PhysicsBSP.Root, resolved),
new FlatCollisionSphere(
boundingSphere.Origin,
boundingSphere.Radius),
flatVisualBounds);
}
/// <summary>
/// Prepares reusable CellStruct collision geometry independently of any
/// EnvCell placement or topology.
/// </summary>
public static FlatCellStructureCollisionAsset FlattenCellStructure(
CellStruct source)
{
ArgumentNullException.ThrowIfNull(source);
FlatPhysicsBsp physicsBsp;
if (source.PhysicsBSP?.Root is null)
{
physicsBsp = EmptyPhysicsBsp();
}
else
{
Dictionary<ushort, ResolvedPolygon> resolved =
PhysicsDataCache.ResolvePolygons(
source.PhysicsPolygons,
source.VertexArray);
physicsBsp = FlattenPhysicsBsp(
source.PhysicsBSP.Root,
resolved);
}
Dictionary<ushort, ResolvedPolygon> portalResolved =
PhysicsDataCache.ResolvePolygons(
source.Polygons,
source.VertexArray);
return new FlatCellStructureCollisionAsset(
physicsBsp,
FlattenCellContainmentBsp(source.CellBSP?.Root),
FlattenPolygonTable(portalResolved));
}
/// <summary>
/// Prepares the per-cell portion of collision publication. Other-cell IDs
/// are expanded with the owning landblock exactly as
/// <see cref="PhysicsDataCache.CacheCellStruct"/> does.
/// </summary>
public static FlatEnvCellTopology FlattenEnvCellTopology(
uint envCellId,
EnvCell source,
FlatPolygonTable portalPolygons)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(portalPolygons);
var portals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
source.CellPortals.Count);
foreach (CellPortal portal in source.CellPortals)
{
ushort polygonId = portal.PolygonId;
if (!portalPolygons.TryFindPolygonIndex(
polygonId,
out int polygonIndex))
{
throw new InvalidDataException(
$"EnvCell 0x{envCellId:X8} portal references missing " +
$"polygon 0x{polygonId:X4}.");
}
portals.Add(new FlatEnvCellPortal(
portal.OtherCellId,
polygonId,
(ushort)portal.Flags,
polygonIndex));
}
uint prefix = envCellId & 0xFFFF_0000u;
uint[] visibleIds = source.VisibleCells is null
? []
: source.VisibleCells
.Select(id => prefix | id)
.Distinct()
.Order()
.ToArray();
return new FlatEnvCellTopology(
portals.MoveToImmutable(),
ImmutableArray.Create(visibleIds),
source.Flags.HasFlag(EnvCellFlags.SeenOutside));
}
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>());
FlatEnvCellTopology topology = FlattenEnvCellTopology(
source.Portals,
source.VisibleCellIds,
source.SeenOutside,
portalPolygons);
var structure = new FlatCellStructureCollisionAsset(
physicsBsp,
containmentBsp,
portalPolygons);
return new FlatCellCollisionAsset(structure, topology);
}
public static FlatEnvCellTopology FlattenEnvCellTopology(
IReadOnlyList<PortalInfo> portals,
IEnumerable<uint> visibleCellIds,
bool seenOutside,
FlatPolygonTable portalPolygons)
{
ArgumentNullException.ThrowIfNull(portals);
ArgumentNullException.ThrowIfNull(visibleCellIds);
ArgumentNullException.ThrowIfNull(portalPolygons);
var flatPortals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
portals.Count);
foreach (PortalInfo portal in portals)
{
if (!portalPolygons.TryFindPolygonIndex(
portal.PolygonId,
out int polygonIndex))
{
throw new InvalidDataException(
$"EnvCell portal references missing polygon " +
$"0x{portal.PolygonId:X4}.");
}
flatPortals.Add(new FlatEnvCellPortal(
portal.OtherCellId,
portal.PolygonId,
portal.Flags,
polygonIndex));
}
uint[] visibleIds = visibleCellIds
.Distinct()
.Order()
.ToArray();
return new FlatEnvCellTopology(
flatPortals.MoveToImmutable(),
ImmutableArray.Create(visibleIds),
seenOutside);
}
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);
}
}