diff --git a/AGENTS.md b/AGENTS.md
index 44b065cf..54e0130f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -159,16 +159,21 @@ with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical
-fresh-vs-reused results and complete reset/reentrancy gates.
+fresh-vs-reused results and complete reset/reentrancy gates. Slice I2 is also
+complete: immutable physics/containment BSP, polygon, Setup, bounds, and
+EnvCell-topology records preserve source float bits and ordering across
+synthetic corruption gates and representative installed DATs without changing
+production traversal.
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
-passes. Slice I is active at I2 from
+passes. Slice I is active at I3 from
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and
`docs/research/2026-07-25-slice-g5-production-profile.md` and
-`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
+`docs/research/2026-07-25-slice-i1-transition-scratch.md` and
+`docs/research/2026-07-25-slice-i2-flat-collision-schema.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
diff --git a/CLAUDE.md b/CLAUDE.md
index aafe37b5..9a5fbe9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,16 +157,21 @@ with CPU/GPU p50 1.869/1.096 ms and 652.1/928.3 MiB working/private memory.
The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical
-fresh-vs-reused results and complete reset/reentrancy gates.
+fresh-vs-reused results and complete reset/reentrancy gates. Slice I2 is also
+complete: immutable physics/containment BSP, polygon, Setup, bounds, and
+EnvCell-topology records preserve source float bits and ordering across
+synthetic corruption gates and representative installed DATs without changing
+production traversal.
Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate
-passes. Slice I is active at I2 from
+passes. Slice I is active at I3 from
`docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and
`docs/research/2026-07-25-slice-g5-production-profile.md` and
-`docs/research/2026-07-25-slice-i1-transition-scratch.md`.
+`docs/research/2026-07-25-slice-i1-transition-scratch.md` and
+`docs/research/2026-07-25-slice-i2-flat-collision-schema.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c05eff4f..48133478 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1650,13 +1650,17 @@ port in any phase — no separate listing here.
> CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated
> transition/query owner: player, remote, projectile, camera, and grounded
> walkable-publication profiles each measure 0 B/resolve, and retained
-> transitions remain bit-identical to the fresh-graph oracle.
+> transitions remain bit-identical to the fresh-graph oracle. Slice I2 now
+> defines deterministic immutable collision records and validates exact
+> source identity over synthetic corruption cases and representative installed
+> DATs; production traversal remains on the graph oracle until the later
+> differential gate.
> The historical exact cutover
> rollback remains
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the
-> connected lifecycle/reconnect gate. Slice I is active at I2 from
+> connected lifecycle/reconnect gate. Slice I is active at I3 from
> [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
@@ -1681,11 +1685,11 @@ hitch and follows.
| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` |
| MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) |
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
-| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; flat collision storage continues in Slice I2. |
+| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; immutable flat collision storage is defined and package integration continues in Slice I3. |
| MP1c | Streaming cutover to pak + hitch gate | ✅ **SHIPPED 2026-07-24** — typed package-only production source, explicit Setup probes, non-allocating Portal-first lookup, exact translucency metadata, and ordered teardown; capped/uncapped/dense physical routes + user visual gate pass. Uncapped CPU p99 7.825→6.496 ms, GPU p99 3.406→2.706 ms, portal frame max 202.9→39.9 MiB, process allocation −41.8%, zero invalid Setup probes. [Report](../research/2026-07-24-slice-c-prepared-asset-cutover-report.md) |
| MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, user-requested 2× default range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m and defaults to 128m; landscape/entity streaming distance unchanged |
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
-| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch shipped; I2 immutable flat collision assets in progress. |
+| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch and I2 immutable flat collision schema shipped; I3 package serialization/bake is active. |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
---
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 2231258b..8b59484e 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -118,10 +118,14 @@ ordinary production profile pass. The final stable production minute sustains
519.7 FPS with CPU/GPU p50 1.869/1.096 ms and 22.34 KiB/frame allocation.
Slice I1 subsequently removes the isolated per-resolve owner: player, remote,
projectile, camera, and grounded walkable-publication profiles are all
-0 B/resolve with bit-identical fresh-vs-reused outcomes. The complete I1
-Release gate is 3,826 App tests / 3 skips and 8,342 solution tests / 5 skips.
+0 B/resolve with bit-identical fresh-vs-reused outcomes. Slice I2 adds
+deterministic immutable physics/containment BSP, polygon, Setup, bounds, and
+EnvCell-topology records without cutting traversal over. Synthetic corruption
+gates and representative installed-DAT source identity pass bit-for-bit. The
+complete I2 Release gate is 3,826 App tests / 3 skips and 8,355 solution tests
+/ 5 skips.
Slice H is complete: retained UI/frame work, exact bounded light selection,
-and pooled/borrowed/direct network I/O pass. Slice I is active at I2 under
+and pooled/borrowed/direct network I/O pass. Slice I is active at I3 under
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The historical exact G4 visual rollback remains
`git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.
diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md
index 8c18fc6b..cb5455de 100644
--- a/docs/plans/2026-07-24-modern-runtime-architecture.md
+++ b/docs/plans/2026-07-24-modern-runtime-architecture.md
@@ -2,8 +2,8 @@
**Date:** 2026-07-24
-**Status:** Slices A–H and I0–I1 are complete. Slices F–L were explicitly
-approved 2026-07-24; Slice I2 is active.
+**Status:** Slices A–H and I0–I2 are complete. Slices F–L were explicitly
+approved 2026-07-24; Slice I3 is active.
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
@@ -1197,12 +1197,18 @@ Frame-thread allocation at the G5 checkpoint was 22.34 KiB median. I1 has now
replaced fresh per-resolve transition graphs and query temporaries with
reset-complete retained scratch: player, remote, projectile, camera, and
grounded walkable-publication profiles each measure 0 B/resolve, while
-fresh-vs-reused output and body state remain bit-identical. I2 immutable flat
-collision assets are the current execution point. Evidence:
+fresh-vs-reused output and body state remain bit-identical. I2 now supplies
+validated immutable flat collision records: synthetic and installed-DAT
+source identity, exact float bits, deterministic pre-order, and corruption
+tripwires pass without changing production traversal. I3 package
+serialization and complete-catalog baking are the current execution point.
+Evidence:
[`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md)
and
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md)
and
+[`../research/2026-07-25-slice-i2-flat-collision-schema.md`](../research/2026-07-25-slice-i2-flat-collision-schema.md)
+and
[`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The intended order is therefore:
@@ -1216,7 +1222,7 @@ honest metrics + committed baselines (A — exit criteria block C)
-> incremental render scene (F)
-> delta GPU submission (G)
-> residual frame cleanup (H-a, H-b, H-c)
- -> flat collision assets (I — CURRENT at I2)
+ -> flat collision assets (I — CURRENT at I3)
-> presentation-independent runtime (J — §0.3 authorization)
-> Linux/headless/multi-session (K)
-> evidence-gated GPU jobs (L)
diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md
index 3d11ed54..e3ac18df 100644
--- a/docs/plans/2026-07-25-modern-runtime-slice-i.md
+++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md
@@ -1,7 +1,7 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics
-**Status:** I0–I1 COMPLETE — retail oracle, graph fixtures, and
-reset-complete zero-allocation transition/query scratch fixed; I2 active
+**Status:** I0–I2 COMPLETE — retail oracle, zero-allocation transition
+scratch, and deterministic immutable collision records fixed; I3 active
**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
**Purpose:** remove parsed DAT object graphs and steady collision allocations
without changing one retail collision decision, float, comparison, or traversal
@@ -146,7 +146,12 @@ Issue #237 is closed. Evidence:
6. Keep source graph and flat asset together only in test/shadow fixtures.
Gate: round-trip structural equality over synthetic edge cases and installed
-DAT samples; no traversal cutover yet.
+DAT samples; no traversal cutover yet. **PASSED 2026-07-25:** 13 focused
+structural/installed-DAT tests, the 131-test expanded Slice I oracle, complete
+Core/solution Release gates, exact float-bit comparisons, and corruption
+tripwires pass. The graph route remains the only executable traversal.
+Evidence:
+[`../research/2026-07-25-slice-i2-flat-collision-schema.md`](../research/2026-07-25-slice-i2-flat-collision-schema.md).
### I3 — package serialization, bake, and prepared collision source
diff --git a/docs/research/2026-07-25-slice-i2-flat-collision-schema.md b/docs/research/2026-07-25-slice-i2-flat-collision-schema.md
new file mode 100644
index 00000000..c7d5a306
--- /dev/null
+++ b/docs/research/2026-07-25-slice-i2-flat-collision-schema.md
@@ -0,0 +1,104 @@
+# Slice I2 — deterministic immutable collision assets
+
+**Status:** COMPLETE
+**Parent:** `docs/plans/2026-07-25-modern-runtime-slice-i.md`
+**Scope:** Core collision storage and preparation only. Production movement,
+BSP traversal, streaming publication, and package format are unchanged.
+
+## Result
+
+Slice I2 defines the immutable representation that will replace parsed
+collision DBObj graphs after the later shadow and differential gates:
+
+- physics BSP nodes in deterministic positive-before-negative pre-order;
+- explicit signed child indices, where `-1` means absent;
+- one ordered leaf polygon-index stream;
+- polygon rows with checked vertex ranges and one contiguous vertex stream;
+- a separate, smaller cell-containment BSP schema;
+- ordered Setup cylinder/sphere rows and verbatim dimensions;
+- separate reusable CellStruct geometry and per-EnvCell topology;
+- direct prepared indices from portals and BSP leaves to polygon rows;
+- optional GfxObj root bounds and visual bounds.
+
+All collections are immutable arrays. Per-EnvCell transforms remain
+publication state and are deliberately absent from reusable CellStruct
+geometry.
+
+## Retail and source ordering
+
+The storage contract is pinned to the existing retail traversal oracle:
+
+- `BSPTREE::find_collisions` `0x0053A440`
+- `BSPNODE::find_walkable` `0x0053CC80`
+- `BSPLEAF::find_walkable` `0x0053D6F0`
+
+The source graph is flattened iteratively. A stack pushes the negative child
+before the positive child, so the output records the current node, then its
+complete positive subtree, then its complete negative subtree. Leaf polygon
+references stay in their stored order. Polygon-table rows are sorted by ID,
+which makes output independent of dictionary insertion and bucket order;
+leaves and portals resolve IDs to direct indices during preparation.
+
+No plane is reconstructed and no vector is normalized. `Plane`, `Vector3`,
+sphere, cylinder, Setup-dimension, and visual-bound floats are copied directly.
+Tests compare all components through `BitConverter.SingleToInt32Bits`.
+
+## Corruption contracts
+
+Construction rejects:
+
+- unsupported BSP node types or child presence inconsistent with the encoded
+ node type;
+- source cycles and shared child nodes;
+- flat cycles, shared children, unreachable nodes, and non-pre-order layouts;
+- child, polygon-index, polygon, or vertex ranges outside their arrays;
+- missing leaf or portal polygon IDs;
+- duplicate/unsorted polygon IDs or visible-cell IDs;
+- polygon key/row-ID disagreement or point-count disagreement;
+- default immutable arrays and null source rows.
+
+An empty tree uses root index `-1`. A populated deterministic tree always uses
+root index `0`.
+
+## Evidence
+
+The synthetic suite covers:
+
+- positive/negative child ordering;
+- multi-polygon leaf order;
+- dictionary-order independence;
+- negative-zero and nontrivial float payload preservation;
+- source mutation after preparation;
+- missing IDs, invalid ranges, malformed child patterns, cycles, shared
+ children, and unreachable/non-pre-order shapes;
+- asymmetric containment trees;
+- a 20,000-node containment tree without call-stack recursion;
+- Setup row order and exact dimensions;
+- portal direct-index resolution and deterministic visible-cell ordering.
+
+Installed retail DAT coverage:
+
+- Facility Hub cells `0x8A02016E` and `0x8A02017A`;
+- Holtburg cottage cell `0xA9B4013F`;
+- cottage, stair/ramp, and multipart-door GfxObjs `0x01000A2B`,
+ `0x01000AC5`, and `0x010044B5`;
+- player, wide-low collision-family, and multipart-door Setups `0x02000001`,
+ `0x020019E3`, and `0x020019FF`.
+
+For every installed sample, tests compare source node type, plane bits,
+positive/negative child identity, leaf index, solid marker, bounds, polygon
+order, polygon metadata, vertex bits, Setup shapes, dimensions, and visual
+bounds.
+
+Release gates:
+
+- I2 focused structural/installed-DAT tests: **13 passed**
+- expanded Slice I graph + flat oracle: **131 passed**
+- complete Core tests: **3,263 passed / 2 skipped**
+- `dotnet build AcDream.slnx -c Release --no-restore`: **passed**
+- complete solution tests: **8,355 passed / 5 skipped**
+
+There is intentionally no traversal cutover and no new retail divergence in
+this commit. Slice I3 serializes these records into the package, adds typed
+prepared collision access, and bakes the complete installed catalog before any
+flat query can affect gameplay.
diff --git a/src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs b/src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
new file mode 100644
index 00000000..d8e75dc0
--- /dev/null
+++ b/src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
@@ -0,0 +1,483 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Physics;
+
+///
+/// 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
+/// BSPTREE::find_collisions (0x0053A440),
+/// BSPNODE::find_walkable (0x0053CC80), and
+/// BSPLEAF::find_walkable (0x0053D6F0).
+///
+public static class FlatCollisionAssetBuilder
+{
+ public static FlatPhysicsBsp FlattenPhysicsBsp(
+ PhysicsBSPNode? root,
+ IReadOnlyDictionary resolved)
+ {
+ ArgumentNullException.ThrowIfNull(resolved);
+
+ FlatPolygonTable polygonTable = FlattenPolygonTable(resolved);
+ if (root is null)
+ {
+ return new FlatPhysicsBsp(
+ -1,
+ ImmutableArray.Empty,
+ ImmutableArray.Empty,
+ polygonTable);
+ }
+
+ var nodes = new List();
+ var polygonIndices = new List();
+ var visited = new Dictionary(
+ ReferenceEqualityComparer.Instance);
+ var stack = new Stack();
+ 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(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.Empty);
+ }
+
+ var nodes = new List();
+ var visited = new Dictionary(
+ ReferenceEqualityComparer.Instance);
+ var stack = new Stack();
+ 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(nodes.Count);
+ foreach (MutableCellNode node in nodes)
+ immutableNodes.Add(node.Freeze());
+
+ return new FlatCellContainmentBsp(0, immutableNodes.MoveToImmutable());
+ }
+
+ public static FlatPolygonTable FlattenPolygonTable(
+ IReadOnlyDictionary resolved)
+ {
+ ArgumentNullException.ThrowIfNull(resolved);
+
+ var ordered = new List>(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(ordered.Count);
+ var vertices = ImmutableArray.CreateBuilder(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(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(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());
+
+ var portals = ImmutableArray.CreateBuilder(
+ 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 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 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 : IEqualityComparer
+ where T : class
+ {
+ public static ReferenceEqualityComparer Instance { get; } = new();
+
+ public bool Equals(T? left, T? right) => ReferenceEquals(left, right);
+
+ public int GetHashCode(T value) => RuntimeHelpers.GetHashCode(value);
+ }
+}
diff --git a/src/AcDream.Core/Physics/FlatCollisionAssets.cs b/src/AcDream.Core/Physics/FlatCollisionAssets.cs
new file mode 100644
index 00000000..90fa50ed
--- /dev/null
+++ b/src/AcDream.Core/Physics/FlatCollisionAssets.cs
@@ -0,0 +1,565 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using DatReaderWriter.Enums;
+
+namespace AcDream.Core.Physics;
+
+///
+/// A checked half-open range into one immutable flat-asset array.
+///
+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}.");
+ }
+ }
+}
+
+///
+/// Verbatim DAT sphere value. No normalization or coordinate conversion is
+/// performed while preparing this record.
+///
+public readonly record struct FlatCollisionSphere(Vector3 Origin, float Radius);
+
+///
+/// Verbatim DAT cylinder-sphere value.
+///
+public readonly record struct FlatCollisionCylinder(
+ Vector3 Origin,
+ float Radius,
+ float Height);
+
+///
+/// One polygon row addressed through .
+///
+public readonly record struct FlatCollisionPolygon(
+ ushort Id,
+ Plane Plane,
+ CullMode SidesType,
+ int NumPoints,
+ FlatIndexRange VertexRange);
+
+///
+/// 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.
+///
+public sealed class FlatPolygonTable
+{
+ public static FlatPolygonTable Empty { get; } = new(
+ ImmutableArray.Empty,
+ ImmutableArray.Empty);
+
+ public FlatPolygonTable(
+ ImmutableArray polygons,
+ ImmutableArray 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 Polygons { get; }
+
+ public ImmutableArray 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;
+ }
+}
+
+///
+/// One physics BSP node. Child index -1 means absent. Polygon indices
+/// address through
+/// .
+///
+public readonly record struct FlatPhysicsBspNode(
+ BSPNodeType Type,
+ Plane SplittingPlane,
+ int PositiveChildIndex,
+ int NegativeChildIndex,
+ int LeafIndex,
+ int Solid,
+ FlatCollisionSphere BoundingSphere,
+ FlatIndexRange PolygonIndexRange);
+
+///
+/// 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.
+///
+public sealed class FlatPhysicsBsp
+{
+ public FlatPhysicsBsp(
+ int rootIndex,
+ ImmutableArray nodes,
+ ImmutableArray 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 Nodes { get; }
+
+ public ImmutableArray PolygonIndexStream { get; }
+
+ public FlatPolygonTable PolygonTable { get; }
+
+ private static void ValidateTree(
+ int rootIndex,
+ ImmutableArray nodes,
+ ImmutableArray 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(
+ int rootIndex,
+ int nodeCount,
+ Func, int, int> getPositive,
+ Func, int, int> getNegative,
+ ImmutableArray nodes,
+ string name)
+ {
+ var visited = new bool[nodeCount];
+ var stack = new Stack();
+ 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).");
+ }
+ }
+}
+
+///
+/// One node in the separate cell-containment BSP.
+///
+public readonly record struct FlatCellBspNode(
+ BSPNodeType Type,
+ Plane SplittingPlane,
+ int PositiveChildIndex,
+ int NegativeChildIndex,
+ int LeafIndex);
+
+///
+/// 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.
+///
+public sealed class FlatCellContainmentBsp
+{
+ public FlatCellContainmentBsp(
+ int rootIndex,
+ ImmutableArray 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 Nodes { get; }
+}
+
+///
+/// Immutable Setup collision payload. List order is the DAT order.
+///
+public sealed class FlatSetupCollision
+{
+ public FlatSetupCollision(
+ ImmutableArray cylinders,
+ ImmutableArray 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 Cylinders { get; }
+
+ public ImmutableArray 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);
+
+///
+/// Immutable GfxObj collision payload prepared independently of rendering.
+///
+public sealed record FlatGfxObjCollisionAsset(
+ FlatPhysicsBsp PhysicsBsp,
+ FlatCollisionSphere? BoundingSphere,
+ FlatGfxObjVisualBounds? VisualBounds);
+
+///
+/// Immutable CellStruct geometry. Per-EnvCell transforms and topology are not
+/// embedded so identical CellStruct geometry can be aliased safely.
+///
+public sealed record FlatCellStructureCollisionAsset(
+ FlatPhysicsBsp PhysicsBsp,
+ FlatCellContainmentBsp ContainmentBsp,
+ FlatPolygonTable PortalPolygons);
+
+///
+/// One per-EnvCell portal row. directly addresses
+/// the owning .
+///
+public readonly record struct FlatEnvCellPortal(
+ ushort OtherCellId,
+ ushort PolygonId,
+ ushort Flags,
+ int PolygonIndex);
+
+///
+/// Per-EnvCell topology kept separate from reusable CellStruct geometry.
+/// Visible cell IDs are sorted to make prepared output deterministic.
+///
+public sealed class FlatEnvCellTopology
+{
+ public FlatEnvCellTopology(
+ ImmutableArray portals,
+ ImmutableArray 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 Portals { get; }
+
+ public ImmutableArray VisibleCellIds { get; }
+
+ public bool SeenOutside { get; }
+}
+
+///
+/// Test/shadow aggregate joining one reusable CellStruct with one EnvCell's
+/// topology. Production package/publication layers keep these lifetimes
+/// separate.
+///
+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}).");
+ }
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/FlatCollisionAssetBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/FlatCollisionAssetBuilderTests.cs
new file mode 100644
index 00000000..e1ee5b70
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/FlatCollisionAssetBuilderTests.cs
@@ -0,0 +1,562 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class FlatCollisionAssetBuilderTests
+{
+ [Fact]
+ public void PhysicsBsp_FlattensPositiveBeforeNegative_AndPreservesBitsAndLeafOrder()
+ {
+ float negativeZero = BitConverter.Int32BitsToSingle(unchecked((int)0x8000_0000));
+ float payload = BitConverter.Int32BitsToSingle(unchecked((int)0x3F12_3456));
+ var polygon4 = Polygon(
+ 4,
+ new Plane(negativeZero, payload, 1f, -2f),
+ CullMode.Clockwise,
+ new Vector3(negativeZero, payload, 3f),
+ new Vector3(4f, 5f, 6f),
+ new Vector3(7f, 8f, 9f));
+ var polygon9 = Polygon(
+ 9,
+ new Plane(-1f, 0f, 0f, 4f),
+ CullMode.CounterClockwise,
+ new Vector3(9f, 8f, 7f),
+ new Vector3(6f, 5f, 4f),
+ new Vector3(3f, 2f, 1f));
+ var resolved = new Dictionary
+ {
+ [9] = polygon9,
+ [4] = polygon4,
+ };
+
+ var positive = Node(BSPNodeType.Leaf, leafIndex: 17);
+ positive.Polygons.Add(9);
+ positive.Polygons.Add(4);
+ var negative = Node(BSPNodeType.Leaf, leafIndex: 23);
+ negative.Polygons.Add(4);
+ negative.Polygons.Add(9);
+ var root = Node(BSPNodeType.BPIN);
+ root.SplittingPlane = new Plane(payload, negativeZero, 0.25f, -12f);
+ root.PosNode = positive;
+ root.NegNode = negative;
+
+ FlatPhysicsBsp flat =
+ FlatCollisionAssetBuilder.FlattenPhysicsBsp(root, resolved);
+
+ Assert.Equal(0, flat.RootIndex);
+ Assert.Equal(3, flat.Nodes.Length);
+ Assert.Equal(1, flat.Nodes[0].PositiveChildIndex);
+ Assert.Equal(2, flat.Nodes[0].NegativeChildIndex);
+ Assert.Equal(17, flat.Nodes[1].LeafIndex);
+ Assert.Equal(23, flat.Nodes[2].LeafIndex);
+ Assert.Equal(new ushort[] { 4, 9 }, flat.PolygonTable.Polygons.Select(p => p.Id));
+ Assert.Equal(new[] { 1, 0, 0, 1 }, flat.PolygonIndexStream);
+ AssertPlaneBits(root.SplittingPlane, flat.Nodes[0].SplittingPlane);
+ AssertPlaneBits(polygon4.Plane, flat.PolygonTable.Polygons[0].Plane);
+ AssertVectorBits(polygon4.Vertices[0], flat.PolygonTable.Vertices[0]);
+
+ // The prepared asset owns immutable copies, not views into source arrays/lists.
+ polygon4.Vertices[0] = new Vector3(100f, 200f, 300f);
+ positive.Polygons.Clear();
+ root.SplittingPlane = default;
+ AssertVectorBits(
+ new Vector3(negativeZero, payload, 3f),
+ flat.PolygonTable.Vertices[0]);
+ Assert.Equal(new[] { 1, 0, 0, 1 }, flat.PolygonIndexStream);
+ AssertPlaneBits(
+ new Plane(payload, negativeZero, 0.25f, -12f),
+ flat.Nodes[0].SplittingPlane);
+ }
+
+ [Fact]
+ public void PhysicsBsp_IsDeterministicAcrossPolygonDictionaryInsertionOrder()
+ {
+ ResolvedPolygon polygon2 = Polygon(
+ 2,
+ new Plane(Vector3.UnitX, 2f),
+ CullMode.None,
+ Vector3.Zero,
+ Vector3.UnitY,
+ Vector3.UnitZ);
+ ResolvedPolygon polygon7 = Polygon(
+ 7,
+ new Plane(Vector3.UnitY, 7f),
+ CullMode.Clockwise,
+ Vector3.One,
+ Vector3.UnitX,
+ Vector3.UnitZ);
+ var first = new Dictionary
+ {
+ [2] = polygon2,
+ [7] = polygon7,
+ };
+ var second = new Dictionary
+ {
+ [7] = polygon7,
+ [2] = polygon2,
+ };
+ PhysicsBSPNode root = Node(BSPNodeType.Leaf);
+ root.Polygons.Add(7);
+ root.Polygons.Add(2);
+
+ FlatPhysicsBsp left =
+ FlatCollisionAssetBuilder.FlattenPhysicsBsp(root, first);
+ FlatPhysicsBsp right =
+ FlatCollisionAssetBuilder.FlattenPhysicsBsp(root, second);
+
+ AssertFlatPhysicsEqual(left, right);
+ }
+
+ [Fact]
+ public void PhysicsBsp_MissingLeafPolygon_IsCorrupt()
+ {
+ PhysicsBSPNode root = Node(BSPNodeType.Leaf);
+ root.Polygons.Add(55);
+
+ InvalidDataException exception = Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ root,
+ new Dictionary()));
+
+ Assert.Contains("0x0037", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PhysicsBsp_CycleAndSharedChild_AreRejected()
+ {
+ PhysicsBSPNode cycle = Node(BSPNodeType.BPIn);
+ cycle.PosNode = cycle;
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ cycle,
+ new Dictionary()));
+
+ PhysicsBSPNode child = Node(BSPNodeType.Leaf);
+ PhysicsBSPNode shared = Node(BSPNodeType.BPIN);
+ shared.PosNode = child;
+ shared.NegNode = child;
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
+ shared,
+ new Dictionary()));
+ }
+
+ [Fact]
+ public void FlatPhysicsSchema_RejectsInvalidRangesChildrenAndShape()
+ {
+ var table = new FlatPolygonTable(
+ ImmutableArray.Create(new FlatCollisionPolygon(
+ 3,
+ new Plane(Vector3.UnitZ, 0f),
+ CullMode.None,
+ 3,
+ new FlatIndexRange(0, 3))),
+ ImmutableArray.Create(Vector3.Zero, Vector3.UnitX, Vector3.UnitY));
+
+ Assert.Throws(() => new FlatPhysicsBsp(
+ 0,
+ ImmutableArray.Create(new FlatPhysicsBspNode(
+ BSPNodeType.BPIn,
+ default,
+ 4,
+ -1,
+ 0,
+ 0,
+ default,
+ default)),
+ ImmutableArray.Empty,
+ table));
+
+ Assert.Throws(() => new FlatPhysicsBsp(
+ 0,
+ ImmutableArray.Create(new FlatPhysicsBspNode(
+ BSPNodeType.Leaf,
+ default,
+ -1,
+ -1,
+ 0,
+ 0,
+ default,
+ new FlatIndexRange(0, 1))),
+ ImmutableArray.Create(7),
+ table));
+
+ Assert.Throws(() => new FlatPhysicsBsp(
+ 0,
+ ImmutableArray.Create(new FlatPhysicsBspNode(
+ BSPNodeType.Leaf,
+ default,
+ 0,
+ -1,
+ 0,
+ 0,
+ default,
+ default)),
+ ImmutableArray.Empty,
+ table));
+
+ var root = new FlatPhysicsBspNode(
+ BSPNodeType.BPIN,
+ default,
+ 2,
+ 1,
+ 0,
+ 0,
+ default,
+ default);
+ var leaf = new FlatPhysicsBspNode(
+ BSPNodeType.Leaf,
+ default,
+ -1,
+ -1,
+ 0,
+ 0,
+ default,
+ default);
+ Assert.Throws(() => new FlatPhysicsBsp(
+ 0,
+ ImmutableArray.Create(root, leaf, leaf),
+ ImmutableArray.Empty,
+ table));
+ }
+
+ [Fact]
+ public void CellContainment_FlattensAsymmetricTreeIteratively()
+ {
+ var positiveLeaf = new CellBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ LeafIndex = 11,
+ };
+ var negativeLeaf = new CellBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ LeafIndex = 22,
+ };
+ var negativeBranch = new CellBSPNode
+ {
+ Type = BSPNodeType.BpnN,
+ SplittingPlane = new Plane(Vector3.UnitY, -5f),
+ NegNode = negativeLeaf,
+ };
+ var root = new CellBSPNode
+ {
+ Type = BSPNodeType.BPIN,
+ SplittingPlane = new Plane(Vector3.UnitX, -3f),
+ PosNode = positiveLeaf,
+ NegNode = negativeBranch,
+ };
+
+ FlatCellContainmentBsp flat =
+ FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
+
+ Assert.Equal(4, flat.Nodes.Length);
+ Assert.Equal((1, 2), (
+ flat.Nodes[0].PositiveChildIndex,
+ flat.Nodes[0].NegativeChildIndex));
+ Assert.Equal(-1, flat.Nodes[2].PositiveChildIndex);
+ Assert.Equal(3, flat.Nodes[2].NegativeChildIndex);
+ Assert.Equal(11, flat.Nodes[1].LeafIndex);
+ Assert.Equal(22, flat.Nodes[3].LeafIndex);
+ AssertPlaneBits(root.SplittingPlane, flat.Nodes[0].SplittingPlane);
+ AssertPlaneBits(
+ negativeBranch.SplittingPlane,
+ flat.Nodes[2].SplittingPlane);
+ }
+
+ [Fact]
+ public void CellContainment_CycleSharedChildAndInvalidFlatChild_AreRejected()
+ {
+ var cycle = new CellBSPNode { Type = BSPNodeType.BPIn };
+ cycle.PosNode = cycle;
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenCellContainmentBsp(cycle));
+
+ var child = new CellBSPNode { Type = BSPNodeType.Leaf };
+ var shared = new CellBSPNode
+ {
+ Type = BSPNodeType.BPIN,
+ PosNode = child,
+ NegNode = child,
+ };
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenCellContainmentBsp(shared));
+
+ Assert.Throws(() => new FlatCellContainmentBsp(
+ 0,
+ ImmutableArray.Create(new FlatCellBspNode(
+ BSPNodeType.BPIn,
+ default,
+ 9,
+ -1,
+ 0))));
+ }
+
+ [Fact]
+ public void CellContainment_DeepTree_DoesNotUseCallStack()
+ {
+ const int depth = 20_000;
+ var root = new CellBSPNode { Type = BSPNodeType.Leaf };
+ for (int i = 1; i < depth; i++)
+ {
+ root = new CellBSPNode
+ {
+ Type = BSPNodeType.BPIn,
+ LeafIndex = i,
+ SplittingPlane = new Plane(Vector3.UnitX, i),
+ PosNode = root,
+ };
+ }
+
+ FlatCellContainmentBsp flat =
+ FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
+
+ Assert.Equal(depth, flat.Nodes.Length);
+ Assert.Equal(-1, flat.Nodes[^1].PositiveChildIndex);
+ }
+
+ [Fact]
+ public void Setup_FlattensOrderedShapesAndExactFloatBits()
+ {
+ float payloadA = BitConverter.Int32BitsToSingle(unchecked((int)0x3F45_6789));
+ float payloadB = BitConverter.Int32BitsToSingle(unchecked((int)0xBF12_3456));
+ var source = new SetupPhysics
+ {
+ CylSpheres =
+ [
+ new CylSphere
+ {
+ Origin = new Vector3(payloadA, 2f, 3f),
+ Radius = payloadB,
+ Height = 5f,
+ },
+ new CylSphere
+ {
+ Origin = new Vector3(6f, 7f, 8f),
+ Radius = 9f,
+ Height = 10f,
+ },
+ ],
+ Spheres =
+ [
+ new Sphere
+ {
+ Origin = new Vector3(11f, payloadB, 13f),
+ Radius = payloadA,
+ },
+ ],
+ Height = payloadA,
+ Radius = payloadB,
+ StepUpHeight = -0f,
+ StepDownHeight = 0.75f,
+ };
+
+ FlatSetupCollision flat = FlatCollisionAssetBuilder.FlattenSetup(source);
+
+ Assert.Equal(2, flat.Cylinders.Length);
+ Assert.Single(flat.Spheres);
+ AssertVectorBits(source.CylSpheres[0].Origin, flat.Cylinders[0].Origin);
+ AssertFloatBits(source.CylSpheres[0].Radius, flat.Cylinders[0].Radius);
+ AssertFloatBits(source.CylSpheres[0].Height, flat.Cylinders[0].Height);
+ AssertVectorBits(source.Spheres[0].Origin, flat.Spheres[0].Origin);
+ AssertFloatBits(source.Spheres[0].Radius, flat.Spheres[0].Radius);
+ AssertFloatBits(source.Height, flat.Height);
+ AssertFloatBits(source.Radius, flat.Radius);
+ AssertFloatBits(source.StepUpHeight, flat.StepUpHeight);
+ AssertFloatBits(source.StepDownHeight, flat.StepDownHeight);
+ }
+
+ [Fact]
+ public void CellAsset_ResolvesPortalIdsAndSortsVisibleCells()
+ {
+ var physicsRoot = Node(BSPNodeType.Leaf);
+ ResolvedPolygon physicsPolygon = Polygon(
+ 1,
+ new Plane(Vector3.UnitZ, 0f),
+ CullMode.None,
+ Vector3.Zero,
+ Vector3.UnitX,
+ Vector3.UnitY);
+ physicsRoot.Polygons.Add(1);
+ ResolvedPolygon portal8 = Polygon(
+ 8,
+ new Plane(Vector3.UnitX, -1f),
+ CullMode.CounterClockwise,
+ Vector3.Zero,
+ Vector3.UnitY,
+ Vector3.UnitZ);
+ ResolvedPolygon portal3 = Polygon(
+ 3,
+ new Plane(Vector3.UnitY, -1f),
+ CullMode.Clockwise,
+ Vector3.Zero,
+ Vector3.UnitX,
+ Vector3.UnitZ);
+ var source = new CellPhysics
+ {
+ BSP = new PhysicsBSPTree { Root = physicsRoot },
+ Resolved = new Dictionary
+ {
+ [1] = physicsPolygon,
+ },
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ PortalPolygons = new Dictionary
+ {
+ [8] = portal8,
+ [3] = portal3,
+ },
+ Portals =
+ [
+ new PortalInfo(0x0102, 8, 4),
+ new PortalInfo(0x0103, 3, 2),
+ ],
+ VisibleCellIds = new HashSet
+ {
+ 0xA9B4_0103,
+ 0xA9B4_0101,
+ 0xA9B4_0102,
+ },
+ SeenOutside = true,
+ };
+
+ FlatCellCollisionAsset flat =
+ FlatCollisionAssetBuilder.FlattenCell(source);
+
+ Assert.Equal(new ushort[] { 3, 8 },
+ flat.Structure.PortalPolygons.Polygons.Select(p => p.Id));
+ Assert.Equal(1, flat.Topology.Portals[0].PolygonIndex);
+ Assert.Equal(0, flat.Topology.Portals[1].PolygonIndex);
+ Assert.Equal(
+ new uint[] { 0xA9B4_0101, 0xA9B4_0102, 0xA9B4_0103 },
+ flat.Topology.VisibleCellIds);
+ Assert.True(flat.Topology.SeenOutside);
+ }
+
+ [Fact]
+ public void PolygonTable_KeyIdentityAndPointCountAreValidated()
+ {
+ ResolvedPolygon mismatchedId = Polygon(
+ 4,
+ default,
+ CullMode.None,
+ Vector3.Zero,
+ Vector3.UnitX,
+ Vector3.UnitY);
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenPolygonTable(
+ new Dictionary { [5] = mismatchedId }));
+
+ var mismatchedCount = new ResolvedPolygon
+ {
+ Id = 6,
+ Plane = default,
+ SidesType = CullMode.None,
+ NumPoints = 99,
+ Vertices = [Vector3.Zero, Vector3.UnitX, Vector3.UnitY],
+ };
+ Assert.Throws(
+ () => FlatCollisionAssetBuilder.FlattenPolygonTable(
+ new Dictionary { [6] = mismatchedCount }));
+ }
+
+ private static PhysicsBSPNode Node(
+ BSPNodeType type,
+ int leafIndex = 0) => new()
+ {
+ Type = type,
+ LeafIndex = leafIndex,
+ BoundingSphere = new Sphere
+ {
+ Origin = new Vector3(1f, 2f, 3f),
+ Radius = 4f,
+ },
+ };
+
+ private static ResolvedPolygon Polygon(
+ ushort id,
+ Plane plane,
+ CullMode sides,
+ params Vector3[] vertices) => new()
+ {
+ Id = id,
+ Plane = plane,
+ SidesType = sides,
+ NumPoints = vertices.Length,
+ Vertices = vertices,
+ };
+
+ internal static void AssertFlatPhysicsEqual(
+ FlatPhysicsBsp expected,
+ FlatPhysicsBsp actual)
+ {
+ Assert.Equal(expected.RootIndex, actual.RootIndex);
+ Assert.Equal(expected.Nodes.Length, actual.Nodes.Length);
+ Assert.Equal(
+ expected.PolygonIndexStream.AsEnumerable(),
+ actual.PolygonIndexStream.AsEnumerable());
+ Assert.Equal(
+ expected.PolygonTable.Polygons.Length,
+ actual.PolygonTable.Polygons.Length);
+ Assert.Equal(
+ expected.PolygonTable.Vertices.Length,
+ actual.PolygonTable.Vertices.Length);
+
+ for (int i = 0; i < expected.Nodes.Length; i++)
+ {
+ FlatPhysicsBspNode left = expected.Nodes[i];
+ FlatPhysicsBspNode right = actual.Nodes[i];
+ Assert.Equal(left.Type, right.Type);
+ AssertPlaneBits(left.SplittingPlane, right.SplittingPlane);
+ Assert.Equal(left.PositiveChildIndex, right.PositiveChildIndex);
+ Assert.Equal(left.NegativeChildIndex, right.NegativeChildIndex);
+ Assert.Equal(left.LeafIndex, right.LeafIndex);
+ Assert.Equal(left.Solid, right.Solid);
+ AssertVectorBits(left.BoundingSphere.Origin, right.BoundingSphere.Origin);
+ AssertFloatBits(left.BoundingSphere.Radius, right.BoundingSphere.Radius);
+ Assert.Equal(left.PolygonIndexRange, right.PolygonIndexRange);
+ }
+
+ for (int i = 0; i < expected.PolygonTable.Polygons.Length; i++)
+ {
+ FlatCollisionPolygon left = expected.PolygonTable.Polygons[i];
+ FlatCollisionPolygon right = actual.PolygonTable.Polygons[i];
+ Assert.Equal(left.Id, right.Id);
+ AssertPlaneBits(left.Plane, right.Plane);
+ Assert.Equal(left.SidesType, right.SidesType);
+ Assert.Equal(left.NumPoints, right.NumPoints);
+ Assert.Equal(left.VertexRange, right.VertexRange);
+ }
+
+ for (int i = 0; i < expected.PolygonTable.Vertices.Length; i++)
+ {
+ AssertVectorBits(
+ expected.PolygonTable.Vertices[i],
+ actual.PolygonTable.Vertices[i]);
+ }
+ }
+
+ internal static void AssertPlaneBits(Plane expected, Plane actual)
+ {
+ AssertVectorBits(expected.Normal, actual.Normal);
+ AssertFloatBits(expected.D, actual.D);
+ }
+
+ internal static void AssertVectorBits(Vector3 expected, Vector3 actual)
+ {
+ AssertFloatBits(expected.X, actual.X);
+ AssertFloatBits(expected.Y, actual.Y);
+ AssertFloatBits(expected.Z, actual.Z);
+ }
+
+ internal static void AssertFloatBits(float expected, float actual) =>
+ Assert.Equal(
+ BitConverter.SingleToInt32Bits(expected),
+ BitConverter.SingleToInt32Bits(actual));
+}
diff --git a/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs b/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs
new file mode 100644
index 00000000..9783af7c
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/FlatCollisionInstalledDatTests.cs
@@ -0,0 +1,330 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Tests.Conformance;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using DatReaderWriter.Types;
+using Xunit.Abstractions;
+
+namespace AcDream.Core.Tests.Physics;
+
+public sealed class FlatCollisionInstalledDatTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public FlatCollisionInstalledDatTests(ITestOutputHelper output) =>
+ _output = output;
+
+ [Fact]
+ public void InstalledCells_FlattenVerbatimAndDeterministically()
+ {
+ string? datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ {
+ _output.WriteLine("SKIP: installed retail DAT directory is unavailable.");
+ return;
+ }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ foreach (uint cellId in new[]
+ {
+ 0x8A02_016Eu, // Facility Hub corridor
+ 0x8A02_017Au, // asymmetric adjoining corridor
+ 0xA9B4_013Fu, // Holtburg cottage
+ })
+ {
+ var cache = new PhysicsDataCache();
+ ConformanceDats.LoadEnvCell(dats, cache, cellId);
+ CellPhysics source = Assert.IsType(
+ cache.GetCellStruct(cellId));
+
+ FlatCellCollisionAsset first =
+ FlatCollisionAssetBuilder.FlattenCell(source);
+ FlatCellCollisionAsset second =
+ FlatCollisionAssetBuilder.FlattenCell(source);
+
+ AssertPhysicsSourceMatches(source.BSP?.Root, source.Resolved, first.Structure.PhysicsBsp);
+ AssertContainmentSourceMatches(
+ source.CellBSP?.Root,
+ first.Structure.ContainmentBsp);
+ FlatCollisionAssetBuilderTests.AssertFlatPhysicsEqual(
+ first.Structure.PhysicsBsp,
+ second.Structure.PhysicsBsp);
+ Assert.Equal(
+ first.Structure.ContainmentBsp.Nodes.AsEnumerable(),
+ second.Structure.ContainmentBsp.Nodes.AsEnumerable());
+ Assert.Equal(
+ first.Topology.Portals.AsEnumerable(),
+ second.Topology.Portals.AsEnumerable());
+ Assert.Equal(
+ first.Topology.VisibleCellIds.AsEnumerable(),
+ second.Topology.VisibleCellIds.AsEnumerable());
+
+ _output.WriteLine(
+ $"cell 0x{cellId:X8}: physicsNodes={first.Structure.PhysicsBsp.Nodes.Length}, " +
+ $"physicsPolygons={first.Structure.PhysicsBsp.PolygonTable.Polygons.Length}, " +
+ $"containmentNodes={first.Structure.ContainmentBsp.Nodes.Length}, " +
+ $"portals={first.Topology.Portals.Length}");
+ }
+ }
+
+ [Fact]
+ public void InstalledGfxObjsAndSetups_FlattenVerbatim()
+ {
+ string? datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null)
+ {
+ _output.WriteLine("SKIP: installed retail DAT directory is unavailable.");
+ return;
+ }
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var cache = new PhysicsDataCache();
+ foreach (uint gfxObjId in new[]
+ {
+ 0x0100_0A2Bu, // Holtburg cottage shell
+ 0x0100_0AC5u, // outdoor stair/ramp fixture
+ 0x0100_44B5u, // multipart door shell
+ })
+ {
+ GfxObj gfxObj = Assert.IsType(dats.Get(gfxObjId));
+ cache.CacheGfxObj(gfxObjId, gfxObj);
+ GfxObjPhysics source = Assert.IsType(
+ cache.GetGfxObj(gfxObjId));
+
+ FlatGfxObjCollisionAsset flat =
+ FlatCollisionAssetBuilder.FlattenGfxObj(
+ source,
+ cache.GetVisualBounds(gfxObjId));
+
+ AssertPhysicsSourceMatches(
+ source.BSP.Root,
+ source.Resolved,
+ flat.PhysicsBsp);
+ AssertNullableSphereBits(source.BoundingSphere, flat.BoundingSphere);
+ Assert.NotNull(flat.VisualBounds);
+ AssertVisualBoundsBits(
+ Assert.IsType(cache.GetVisualBounds(gfxObjId)),
+ flat.VisualBounds!.Value);
+
+ _output.WriteLine(
+ $"gfx 0x{gfxObjId:X8}: nodes={flat.PhysicsBsp.Nodes.Length}, " +
+ $"polygons={flat.PhysicsBsp.PolygonTable.Polygons.Length}");
+ }
+
+ foreach (uint setupId in new[]
+ {
+ 0x0200_0001u, // player
+ 0x0200_19E3u, // wide low collision family
+ 0x0200_19FFu, // multipart door
+ })
+ {
+ Setup setup = Assert.IsType(dats.Get(setupId));
+ cache.CacheSetup(setupId, setup);
+ SetupPhysics source = Assert.IsType(
+ cache.GetSetup(setupId));
+
+ FlatSetupCollision flat =
+ FlatCollisionAssetBuilder.FlattenSetup(source);
+ AssertSetupSourceMatches(source, flat);
+
+ _output.WriteLine(
+ $"setup 0x{setupId:X8}: cylinders={flat.Cylinders.Length}, " +
+ $"spheres={flat.Spheres.Length}");
+ }
+ }
+
+ private static void AssertPhysicsSourceMatches(
+ PhysicsBSPNode? root,
+ IReadOnlyDictionary resolved,
+ FlatPhysicsBsp flat)
+ {
+ if (root is null)
+ {
+ Assert.Equal(-1, flat.RootIndex);
+ Assert.Empty(flat.Nodes);
+ return;
+ }
+
+ var ordered = new List();
+ var indices = new Dictionary(
+ ReferenceEqualityComparer.Instance);
+ var stack = new Stack();
+ stack.Push(root);
+ while (stack.Count != 0)
+ {
+ PhysicsBSPNode source = stack.Pop();
+ indices.Add(source, ordered.Count);
+ ordered.Add(source);
+ if (source.NegNode is not null)
+ stack.Push(source.NegNode);
+ if (source.PosNode is not null)
+ stack.Push(source.PosNode);
+ }
+
+ Assert.Equal(flat.Nodes.Length, ordered.Count);
+ for (int flatIndex = 0; flatIndex < ordered.Count; flatIndex++)
+ {
+ PhysicsBSPNode source = ordered[flatIndex];
+ FlatPhysicsBspNode row = flat.Nodes[flatIndex];
+ Assert.Equal(source.Type, row.Type);
+ FlatCollisionAssetBuilderTests.AssertPlaneBits(
+ source.SplittingPlane,
+ row.SplittingPlane);
+ Assert.Equal(source.LeafIndex, row.LeafIndex);
+ Assert.Equal(source.Solid, row.Solid);
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.BoundingSphere.Origin,
+ row.BoundingSphere.Origin);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.BoundingSphere.Radius,
+ row.BoundingSphere.Radius);
+ Assert.Equal(
+ source.PosNode is null ? -1 : indices[source.PosNode],
+ row.PositiveChildIndex);
+ Assert.Equal(
+ source.NegNode is null ? -1 : indices[source.NegNode],
+ row.NegativeChildIndex);
+ Assert.Equal(source.Polygons.Count, row.PolygonIndexRange.Count);
+
+ for (int i = 0; i < source.Polygons.Count; i++)
+ {
+ int streamIndex = row.PolygonIndexRange.Start + i;
+ int polygonIndex = flat.PolygonIndexStream[streamIndex];
+ Assert.Equal(
+ source.Polygons[i],
+ flat.PolygonTable.Polygons[polygonIndex].Id);
+ }
+ }
+
+ Assert.Equal(resolved.Count, flat.PolygonTable.Polygons.Length);
+ foreach ((ushort id, ResolvedPolygon source) in resolved)
+ {
+ Assert.True(flat.PolygonTable.TryFindPolygonIndex(id, out int polygonIndex));
+ FlatCollisionPolygon row = flat.PolygonTable.Polygons[polygonIndex];
+ Assert.Equal(source.Id, row.Id);
+ Assert.Equal(source.NumPoints, row.NumPoints);
+ Assert.Equal(source.SidesType, row.SidesType);
+ FlatCollisionAssetBuilderTests.AssertPlaneBits(source.Plane, row.Plane);
+ for (int i = 0; i < source.Vertices.Length; i++)
+ {
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.Vertices[i],
+ flat.PolygonTable.Vertices[row.VertexRange.Start + i]);
+ }
+ }
+ }
+
+ private static void AssertContainmentSourceMatches(
+ CellBSPNode? root,
+ FlatCellContainmentBsp flat)
+ {
+ if (root is null)
+ {
+ Assert.Equal(-1, flat.RootIndex);
+ Assert.Empty(flat.Nodes);
+ return;
+ }
+
+ var ordered = new List();
+ var indices = new Dictionary(
+ ReferenceEqualityComparer.Instance);
+ var stack = new Stack();
+ stack.Push(root);
+ while (stack.Count != 0)
+ {
+ CellBSPNode source = stack.Pop();
+ indices.Add(source, ordered.Count);
+ ordered.Add(source);
+ if (source.NegNode is not null)
+ stack.Push(source.NegNode);
+ if (source.PosNode is not null)
+ stack.Push(source.PosNode);
+ }
+
+ Assert.Equal(flat.Nodes.Length, ordered.Count);
+ for (int flatIndex = 0; flatIndex < ordered.Count; flatIndex++)
+ {
+ CellBSPNode source = ordered[flatIndex];
+ FlatCellBspNode row = flat.Nodes[flatIndex];
+ Assert.Equal(source.Type, row.Type);
+ Assert.Equal(source.LeafIndex, row.LeafIndex);
+ FlatCollisionAssetBuilderTests.AssertPlaneBits(
+ source.SplittingPlane,
+ row.SplittingPlane);
+ Assert.Equal(
+ source.PosNode is null ? -1 : indices[source.PosNode],
+ row.PositiveChildIndex);
+ Assert.Equal(
+ source.NegNode is null ? -1 : indices[source.NegNode],
+ row.NegativeChildIndex);
+ }
+ }
+
+ private static void AssertSetupSourceMatches(
+ SetupPhysics source,
+ FlatSetupCollision flat)
+ {
+ Assert.Equal(source.CylSpheres.Count, flat.Cylinders.Length);
+ Assert.Equal(source.Spheres.Count, flat.Spheres.Length);
+ for (int i = 0; i < source.CylSpheres.Count; i++)
+ {
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.CylSpheres[i].Origin,
+ flat.Cylinders[i].Origin);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.CylSpheres[i].Radius,
+ flat.Cylinders[i].Radius);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.CylSpheres[i].Height,
+ flat.Cylinders[i].Height);
+ }
+ for (int i = 0; i < source.Spheres.Count; i++)
+ {
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.Spheres[i].Origin,
+ flat.Spheres[i].Origin);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.Spheres[i].Radius,
+ flat.Spheres[i].Radius);
+ }
+ FlatCollisionAssetBuilderTests.AssertFloatBits(source.Height, flat.Height);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(source.Radius, flat.Radius);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.StepUpHeight,
+ flat.StepUpHeight);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.StepDownHeight,
+ flat.StepDownHeight);
+ }
+
+ private static void AssertNullableSphereBits(
+ Sphere? source,
+ FlatCollisionSphere? flat)
+ {
+ Assert.Equal(source is null, flat is null);
+ if (source is null || flat is null)
+ return;
+
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.Origin,
+ flat.Value.Origin);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(
+ source.Radius,
+ flat.Value.Radius);
+ }
+
+ private static void AssertVisualBoundsBits(
+ GfxObjVisualBounds source,
+ FlatGfxObjVisualBounds flat)
+ {
+ FlatCollisionAssetBuilderTests.AssertVectorBits(source.Min, flat.Min);
+ FlatCollisionAssetBuilderTests.AssertVectorBits(source.Max, flat.Max);
+ FlatCollisionAssetBuilderTests.AssertVectorBits(source.Center, flat.Center);
+ FlatCollisionAssetBuilderTests.AssertFloatBits(source.Radius, flat.Radius);
+ FlatCollisionAssetBuilderTests.AssertVectorBits(
+ source.HalfExtents,
+ flat.HalfExtents);
+ }
+}