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); + } +}