feat(physics): define deterministic flat collision assets

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

View file

@ -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 The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical 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 Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate 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 `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence: the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and `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-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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -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 The G5 profile's 22.34 KiB/frame remainder led into Slice I1. That slice is
now complete: player, remote, projectile, camera, and grounded now complete: player, remote, projectile, camera, and grounded
walkable-publication profiles each measure 0 B/resolve, with bit-identical 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 Slice H's retained UI/frame, bounded-light, and pooled/borrowed/direct network
work is complete; the exact seven-checkpoint connected lifecycle/reconnect gate 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 `docs/plans/2026-07-25-modern-runtime-slice-i.md`. The exact G4 visual rollback
is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or
the portal-warmup corrections. Evidence: the portal-warmup corrections. Evidence:
`docs/research/2026-07-25-slice-h-closeout.md` and `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-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 **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -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 > CPU/GPU p50 1.869/1.096 ms. Slice I1 now removes the isolated
> transition/query owner: player, remote, projectile, camera, and grounded > transition/query owner: player, remote, projectile, camera, and grounded
> walkable-publication profiles each measure 0 B/resolve, and retained > 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 > The historical exact cutover
> rollback remains > rollback remains
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and > retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the > 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). > [its detailed plan](2026-07-25-modern-runtime-slice-i.md).
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the **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` | | 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) | | 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 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | | **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 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/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) | | 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 | | 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 | | 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 | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
--- ---

View file

@ -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. 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, Slice I1 subsequently removes the isolated per-resolve owner: player, remote,
projectile, camera, and grounded walkable-publication profiles are all projectile, camera, and grounded walkable-publication profiles are all
0 B/resolve with bit-identical fresh-vs-reused outcomes. The complete I1 0 B/resolve with bit-identical fresh-vs-reused outcomes. Slice I2 adds
Release gate is 3,826 App tests / 3 skips and 8,342 solution tests / 5 skips. 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, 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). [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The historical exact G4 visual rollback remains The historical exact G4 visual rollback remains
`git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`.

View file

@ -2,8 +2,8 @@
**Date:** 2026-07-24 **Date:** 2026-07-24
**Status:** Slices AH and I0I1 are complete. Slices FL were explicitly **Status:** Slices AH and I0I2 are complete. Slices FL were explicitly
approved 2026-07-24; Slice I2 is active. approved 2026-07-24; Slice I3 is active.
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. 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 replaced fresh per-resolve transition graphs and query temporaries with
reset-complete retained scratch: player, remote, projectile, camera, and reset-complete retained scratch: player, remote, projectile, camera, and
grounded walkable-publication profiles each measure 0 B/resolve, while grounded walkable-publication profiles each measure 0 B/resolve, while
fresh-vs-reused output and body state remain bit-identical. I2 immutable flat fresh-vs-reused output and body state remain bit-identical. I2 now supplies
collision assets are the current execution point. Evidence: 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) [`../research/2026-07-25-slice-g5-production-profile.md`](../research/2026-07-25-slice-g5-production-profile.md)
and and
[`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md) [`../research/2026-07-25-slice-i1-transition-scratch.md`](../research/2026-07-25-slice-i1-transition-scratch.md)
and 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). [`2026-07-25-modern-runtime-slice-i.md`](2026-07-25-modern-runtime-slice-i.md).
The intended order is therefore: The intended order is therefore:
@ -1216,7 +1222,7 @@ honest metrics + committed baselines (A — exit criteria block C)
-> incremental render scene (F) -> incremental render scene (F)
-> delta GPU submission (G) -> delta GPU submission (G)
-> residual frame cleanup (H-a, H-b, H-c) -> 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) -> presentation-independent runtime (J — §0.3 authorization)
-> Linux/headless/multi-session (K) -> Linux/headless/multi-session (K)
-> evidence-gated GPU jobs (L) -> evidence-gated GPU jobs (L)

View file

@ -1,7 +1,7 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics # Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0I1 COMPLETE — retail oracle, graph fixtures, and **Status:** I0I2 COMPLETE — retail oracle, zero-allocation transition
reset-complete zero-allocation transition/query scratch fixed; I2 active scratch, and deterministic immutable collision records fixed; I3 active
**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I **Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice I
**Purpose:** remove parsed DAT object graphs and steady collision allocations **Purpose:** remove parsed DAT object graphs and steady collision allocations
without changing one retail collision decision, float, comparison, or traversal 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. 6. Keep source graph and flat asset together only in test/shadow fixtures.
Gate: round-trip structural equality over synthetic edge cases and installed 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 ### I3 — package serialization, bake, and prepared collision source

View file

@ -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.

View file

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

View file

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

View file

@ -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<ushort, ResolvedPolygon>
{
[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<ushort, ResolvedPolygon>
{
[2] = polygon2,
[7] = polygon7,
};
var second = new Dictionary<ushort, ResolvedPolygon>
{
[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<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
root,
new Dictionary<ushort, ResolvedPolygon>()));
Assert.Contains("0x0037", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void PhysicsBsp_CycleAndSharedChild_AreRejected()
{
PhysicsBSPNode cycle = Node(BSPNodeType.BPIn);
cycle.PosNode = cycle;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
cycle,
new Dictionary<ushort, ResolvedPolygon>()));
PhysicsBSPNode child = Node(BSPNodeType.Leaf);
PhysicsBSPNode shared = Node(BSPNodeType.BPIN);
shared.PosNode = child;
shared.NegNode = child;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenPhysicsBsp(
shared,
new Dictionary<ushort, ResolvedPolygon>()));
}
[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<InvalidDataException>(() => new FlatPhysicsBsp(
0,
ImmutableArray.Create(new FlatPhysicsBspNode(
BSPNodeType.BPIn,
default,
4,
-1,
0,
0,
default,
default)),
ImmutableArray<int>.Empty,
table));
Assert.Throws<InvalidDataException>(() => 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<InvalidDataException>(() => new FlatPhysicsBsp(
0,
ImmutableArray.Create(new FlatPhysicsBspNode(
BSPNodeType.Leaf,
default,
0,
-1,
0,
0,
default,
default)),
ImmutableArray<int>.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<InvalidDataException>(() => new FlatPhysicsBsp(
0,
ImmutableArray.Create(root, leaf, leaf),
ImmutableArray<int>.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<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenCellContainmentBsp(cycle));
var child = new CellBSPNode { Type = BSPNodeType.Leaf };
var shared = new CellBSPNode
{
Type = BSPNodeType.BPIN,
PosNode = child,
NegNode = child,
};
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenCellContainmentBsp(shared));
Assert.Throws<InvalidDataException>(() => 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<ushort, ResolvedPolygon>
{
[1] = physicsPolygon,
},
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
},
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{
[8] = portal8,
[3] = portal3,
},
Portals =
[
new PortalInfo(0x0102, 8, 4),
new PortalInfo(0x0103, 3, 2),
],
VisibleCellIds = new HashSet<uint>
{
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<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenPolygonTable(
new Dictionary<ushort, ResolvedPolygon> { [5] = mismatchedId }));
var mismatchedCount = new ResolvedPolygon
{
Id = 6,
Plane = default,
SidesType = CullMode.None,
NumPoints = 99,
Vertices = [Vector3.Zero, Vector3.UnitX, Vector3.UnitY],
};
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetBuilder.FlattenPolygonTable(
new Dictionary<ushort, ResolvedPolygon> { [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));
}

View file

@ -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<CellPhysics>(
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<GfxObj>(dats.Get<GfxObj>(gfxObjId));
cache.CacheGfxObj(gfxObjId, gfxObj);
GfxObjPhysics source = Assert.IsType<GfxObjPhysics>(
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<GfxObjVisualBounds>(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<Setup>(dats.Get<Setup>(setupId));
cache.CacheSetup(setupId, setup);
SetupPhysics source = Assert.IsType<SetupPhysics>(
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<ushort, ResolvedPolygon> resolved,
FlatPhysicsBsp flat)
{
if (root is null)
{
Assert.Equal(-1, flat.RootIndex);
Assert.Empty(flat.Nodes);
return;
}
var ordered = new List<PhysicsBSPNode>();
var indices = new Dictionary<PhysicsBSPNode, int>(
ReferenceEqualityComparer.Instance);
var stack = new Stack<PhysicsBSPNode>();
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<CellBSPNode>();
var indices = new Dictionary<CellBSPNode, int>(
ReferenceEqualityComparer.Instance);
var stack = new Stack<CellBSPNode>();
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);
}
}