feat(physics): define deterministic flat collision assets
Add immutable indexed physics and containment BSP records, exact-bit polygon and Setup payloads, separated CellStruct/topology ownership, and iterative positive-before-negative flattening. Reject malformed graphs and ranges, and prove source identity over synthetic edge cases and installed retail DAT samples without cutting production traversal over.
This commit is contained in:
parent
16d182c2f0
commit
d9300c7854
11 changed files with 2094 additions and 21 deletions
11
AGENTS.md
11
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
|
||||
|
|
|
|||
11
CLAUDE.md
11
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
104
docs/research/2026-07-25-slice-i2-flat-collision-schema.md
Normal file
104
docs/research/2026-07-25-slice-i2-flat-collision-schema.md
Normal 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.
|
||||
483
src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
Normal file
483
src/AcDream.Core/Physics/FlatCollisionAssetBuilder.cs
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic preparation from the current parsed-DAT graph oracle into
|
||||
/// immutable flat collision records. This class does not perform traversal.
|
||||
/// Source field and ordering contracts are pinned from retail
|
||||
/// <c>BSPTREE::find_collisions</c> (0x0053A440),
|
||||
/// <c>BSPNODE::find_walkable</c> (0x0053CC80), and
|
||||
/// <c>BSPLEAF::find_walkable</c> (0x0053D6F0).
|
||||
/// </summary>
|
||||
public static class FlatCollisionAssetBuilder
|
||||
{
|
||||
public static FlatPhysicsBsp FlattenPhysicsBsp(
|
||||
PhysicsBSPNode? root,
|
||||
IReadOnlyDictionary<ushort, ResolvedPolygon> resolved)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolved);
|
||||
|
||||
FlatPolygonTable polygonTable = FlattenPolygonTable(resolved);
|
||||
if (root is null)
|
||||
{
|
||||
return new FlatPhysicsBsp(
|
||||
-1,
|
||||
ImmutableArray<FlatPhysicsBspNode>.Empty,
|
||||
ImmutableArray<int>.Empty,
|
||||
polygonTable);
|
||||
}
|
||||
|
||||
var nodes = new List<MutablePhysicsNode>();
|
||||
var polygonIndices = new List<int>();
|
||||
var visited = new Dictionary<PhysicsBSPNode, int>(
|
||||
ReferenceEqualityComparer<PhysicsBSPNode>.Instance);
|
||||
var stack = new Stack<PhysicsNodeFrame>();
|
||||
stack.Push(new PhysicsNodeFrame(root, -1, ChildEdge.Root));
|
||||
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
PhysicsNodeFrame frame = stack.Pop();
|
||||
FlatBspNodeContract.Validate(
|
||||
frame.Node.Type,
|
||||
frame.Node.PosNode is not null,
|
||||
frame.Node.NegNode is not null,
|
||||
"physics BSP source node");
|
||||
if (visited.ContainsKey(frame.Node))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Physics BSP source contains a cycle or shared child.");
|
||||
}
|
||||
|
||||
int nodeIndex = nodes.Count;
|
||||
visited.Add(frame.Node, nodeIndex);
|
||||
AttachChild(nodes, frame.ParentIndex, frame.Edge, nodeIndex);
|
||||
|
||||
int polygonStart = polygonIndices.Count;
|
||||
if (frame.Node.Polygons is null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Physics BSP node {nodeIndex} has no polygon-reference list.");
|
||||
}
|
||||
foreach (ushort polygonId in frame.Node.Polygons)
|
||||
{
|
||||
if (!polygonTable.TryFindPolygonIndex(polygonId, out int polygonIndex))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Physics BSP leaf references missing polygon 0x{polygonId:X4}.");
|
||||
}
|
||||
polygonIndices.Add(polygonIndex);
|
||||
}
|
||||
|
||||
Sphere boundingSphere = frame.Node.BoundingSphere
|
||||
?? throw new InvalidDataException(
|
||||
$"Physics BSP node {nodeIndex} has no bounding sphere.");
|
||||
nodes.Add(new MutablePhysicsNode(
|
||||
frame.Node.Type,
|
||||
frame.Node.SplittingPlane,
|
||||
frame.Node.LeafIndex,
|
||||
frame.Node.Solid,
|
||||
new FlatCollisionSphere(
|
||||
boundingSphere.Origin,
|
||||
boundingSphere.Radius),
|
||||
new FlatIndexRange(
|
||||
polygonStart,
|
||||
polygonIndices.Count - polygonStart)));
|
||||
|
||||
// Stack order is reversed so positive is emitted before negative.
|
||||
if (frame.Node.NegNode is not null)
|
||||
{
|
||||
stack.Push(new PhysicsNodeFrame(
|
||||
frame.Node.NegNode,
|
||||
nodeIndex,
|
||||
ChildEdge.Negative));
|
||||
}
|
||||
if (frame.Node.PosNode is not null)
|
||||
{
|
||||
stack.Push(new PhysicsNodeFrame(
|
||||
frame.Node.PosNode,
|
||||
nodeIndex,
|
||||
ChildEdge.Positive));
|
||||
}
|
||||
}
|
||||
|
||||
var immutableNodes = ImmutableArray.CreateBuilder<FlatPhysicsBspNode>(nodes.Count);
|
||||
foreach (MutablePhysicsNode node in nodes)
|
||||
immutableNodes.Add(node.Freeze());
|
||||
|
||||
return new FlatPhysicsBsp(
|
||||
0,
|
||||
immutableNodes.MoveToImmutable(),
|
||||
ImmutableArray.CreateRange(polygonIndices),
|
||||
polygonTable);
|
||||
}
|
||||
|
||||
public static FlatCellContainmentBsp FlattenCellContainmentBsp(
|
||||
CellBSPNode? root)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
return new FlatCellContainmentBsp(
|
||||
-1,
|
||||
ImmutableArray<FlatCellBspNode>.Empty);
|
||||
}
|
||||
|
||||
var nodes = new List<MutableCellNode>();
|
||||
var visited = new Dictionary<CellBSPNode, int>(
|
||||
ReferenceEqualityComparer<CellBSPNode>.Instance);
|
||||
var stack = new Stack<CellNodeFrame>();
|
||||
stack.Push(new CellNodeFrame(root, -1, ChildEdge.Root));
|
||||
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
CellNodeFrame frame = stack.Pop();
|
||||
FlatBspNodeContract.Validate(
|
||||
frame.Node.Type,
|
||||
frame.Node.PosNode is not null,
|
||||
frame.Node.NegNode is not null,
|
||||
"cell-containment BSP source node");
|
||||
if (visited.ContainsKey(frame.Node))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Cell-containment BSP source contains a cycle or shared child.");
|
||||
}
|
||||
|
||||
int nodeIndex = nodes.Count;
|
||||
visited.Add(frame.Node, nodeIndex);
|
||||
AttachChild(nodes, frame.ParentIndex, frame.Edge, nodeIndex);
|
||||
nodes.Add(new MutableCellNode(
|
||||
frame.Node.Type,
|
||||
frame.Node.SplittingPlane,
|
||||
frame.Node.LeafIndex));
|
||||
|
||||
if (frame.Node.NegNode is not null)
|
||||
{
|
||||
stack.Push(new CellNodeFrame(
|
||||
frame.Node.NegNode,
|
||||
nodeIndex,
|
||||
ChildEdge.Negative));
|
||||
}
|
||||
if (frame.Node.PosNode is not null)
|
||||
{
|
||||
stack.Push(new CellNodeFrame(
|
||||
frame.Node.PosNode,
|
||||
nodeIndex,
|
||||
ChildEdge.Positive));
|
||||
}
|
||||
}
|
||||
|
||||
var immutableNodes = ImmutableArray.CreateBuilder<FlatCellBspNode>(nodes.Count);
|
||||
foreach (MutableCellNode node in nodes)
|
||||
immutableNodes.Add(node.Freeze());
|
||||
|
||||
return new FlatCellContainmentBsp(0, immutableNodes.MoveToImmutable());
|
||||
}
|
||||
|
||||
public static FlatPolygonTable FlattenPolygonTable(
|
||||
IReadOnlyDictionary<ushort, ResolvedPolygon> resolved)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolved);
|
||||
|
||||
var ordered = new List<KeyValuePair<ushort, ResolvedPolygon>>(resolved);
|
||||
ordered.Sort(static (left, right) => left.Key.CompareTo(right.Key));
|
||||
|
||||
int vertexCount = 0;
|
||||
foreach ((ushort id, ResolvedPolygon source) in ordered)
|
||||
{
|
||||
ValidateSourcePolygon(id, source);
|
||||
vertexCount = checked(vertexCount + source.Vertices.Length);
|
||||
}
|
||||
|
||||
var polygons = ImmutableArray.CreateBuilder<FlatCollisionPolygon>(ordered.Count);
|
||||
var vertices = ImmutableArray.CreateBuilder<Vector3>(vertexCount);
|
||||
foreach ((ushort id, ResolvedPolygon source) in ordered)
|
||||
{
|
||||
int vertexStart = vertices.Count;
|
||||
foreach (Vector3 vertex in source.Vertices)
|
||||
vertices.Add(vertex);
|
||||
|
||||
polygons.Add(new FlatCollisionPolygon(
|
||||
id,
|
||||
source.Plane,
|
||||
source.SidesType,
|
||||
source.NumPoints,
|
||||
new FlatIndexRange(
|
||||
vertexStart,
|
||||
vertices.Count - vertexStart)));
|
||||
}
|
||||
|
||||
return new FlatPolygonTable(
|
||||
polygons.MoveToImmutable(),
|
||||
vertices.MoveToImmutable());
|
||||
}
|
||||
|
||||
private static void ValidateSourcePolygon(
|
||||
ushort id,
|
||||
ResolvedPolygon source)
|
||||
{
|
||||
if (source is null)
|
||||
throw new InvalidDataException($"Polygon 0x{id:X4} is null.");
|
||||
if (source.Id != id)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Polygon dictionary key 0x{id:X4} does not match row ID " +
|
||||
$"0x{source.Id:X4}.");
|
||||
}
|
||||
if (source.Vertices is null)
|
||||
throw new InvalidDataException($"Polygon 0x{id:X4} has no vertices.");
|
||||
if (source.NumPoints != source.Vertices.Length)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Polygon 0x{id:X4} NumPoints {source.NumPoints} does not " +
|
||||
$"match vertex count {source.Vertices.Length}.");
|
||||
}
|
||||
}
|
||||
|
||||
public static FlatSetupCollision FlattenSetup(SetupPhysics source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var cylinders =
|
||||
ImmutableArray.CreateBuilder<FlatCollisionCylinder>(source.CylSpheres.Count);
|
||||
foreach (CylSphere cylinder in source.CylSpheres)
|
||||
{
|
||||
if (cylinder is null)
|
||||
throw new InvalidDataException("Setup cylinder row is null.");
|
||||
cylinders.Add(new FlatCollisionCylinder(
|
||||
cylinder.Origin,
|
||||
cylinder.Radius,
|
||||
cylinder.Height));
|
||||
}
|
||||
|
||||
var spheres =
|
||||
ImmutableArray.CreateBuilder<FlatCollisionSphere>(source.Spheres.Count);
|
||||
foreach (Sphere sphere in source.Spheres)
|
||||
{
|
||||
if (sphere is null)
|
||||
throw new InvalidDataException("Setup sphere row is null.");
|
||||
spheres.Add(new FlatCollisionSphere(sphere.Origin, sphere.Radius));
|
||||
}
|
||||
|
||||
return new FlatSetupCollision(
|
||||
cylinders.MoveToImmutable(),
|
||||
spheres.MoveToImmutable(),
|
||||
source.Height,
|
||||
source.Radius,
|
||||
source.StepUpHeight,
|
||||
source.StepDownHeight);
|
||||
}
|
||||
|
||||
public static FlatGfxObjCollisionAsset FlattenGfxObj(
|
||||
GfxObjPhysics source,
|
||||
GfxObjVisualBounds? visualBounds = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(source.BSP);
|
||||
|
||||
FlatCollisionSphere? boundingSphere = source.BoundingSphere is null
|
||||
? null
|
||||
: new FlatCollisionSphere(
|
||||
source.BoundingSphere.Origin,
|
||||
source.BoundingSphere.Radius);
|
||||
FlatGfxObjVisualBounds? flatVisualBounds = visualBounds is null
|
||||
? null
|
||||
: new FlatGfxObjVisualBounds(
|
||||
visualBounds.Min,
|
||||
visualBounds.Max,
|
||||
visualBounds.Center,
|
||||
visualBounds.Radius,
|
||||
visualBounds.HalfExtents);
|
||||
|
||||
return new FlatGfxObjCollisionAsset(
|
||||
FlattenPhysicsBsp(source.BSP.Root, source.Resolved),
|
||||
boundingSphere,
|
||||
flatVisualBounds);
|
||||
}
|
||||
|
||||
public static FlatCellCollisionAsset FlattenCell(CellPhysics source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
FlatPhysicsBsp physicsBsp = FlattenPhysicsBsp(
|
||||
source.BSP?.Root,
|
||||
source.Resolved);
|
||||
FlatCellContainmentBsp containmentBsp =
|
||||
FlattenCellContainmentBsp(source.CellBSP?.Root);
|
||||
FlatPolygonTable portalPolygons = FlattenPolygonTable(
|
||||
source.PortalPolygons
|
||||
?? new Dictionary<ushort, ResolvedPolygon>());
|
||||
|
||||
var portals = ImmutableArray.CreateBuilder<FlatEnvCellPortal>(
|
||||
source.Portals.Count);
|
||||
foreach (PortalInfo portal in source.Portals)
|
||||
{
|
||||
if (!portalPolygons.TryFindPolygonIndex(
|
||||
portal.PolygonId,
|
||||
out int polygonIndex))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"EnvCell portal references missing polygon " +
|
||||
$"0x{portal.PolygonId:X4}.");
|
||||
}
|
||||
|
||||
portals.Add(new FlatEnvCellPortal(
|
||||
portal.OtherCellId,
|
||||
portal.PolygonId,
|
||||
portal.Flags,
|
||||
polygonIndex));
|
||||
}
|
||||
|
||||
uint[] visibleIds = source.VisibleCellIds.ToArray();
|
||||
Array.Sort(visibleIds);
|
||||
var topology = new FlatEnvCellTopology(
|
||||
portals.MoveToImmutable(),
|
||||
ImmutableArray.Create(visibleIds),
|
||||
source.SeenOutside);
|
||||
var structure = new FlatCellStructureCollisionAsset(
|
||||
physicsBsp,
|
||||
containmentBsp,
|
||||
portalPolygons);
|
||||
return new FlatCellCollisionAsset(structure, topology);
|
||||
}
|
||||
|
||||
private static void AttachChild(
|
||||
List<MutablePhysicsNode> nodes,
|
||||
int parentIndex,
|
||||
ChildEdge edge,
|
||||
int childIndex)
|
||||
{
|
||||
if (edge == ChildEdge.Root)
|
||||
return;
|
||||
if ((uint)parentIndex >= (uint)nodes.Count)
|
||||
throw new InvalidDataException("Physics BSP child has no valid parent.");
|
||||
|
||||
MutablePhysicsNode parent = nodes[parentIndex];
|
||||
if (edge == ChildEdge.Positive)
|
||||
parent.PositiveChildIndex = childIndex;
|
||||
else
|
||||
parent.NegativeChildIndex = childIndex;
|
||||
}
|
||||
|
||||
private static void AttachChild(
|
||||
List<MutableCellNode> nodes,
|
||||
int parentIndex,
|
||||
ChildEdge edge,
|
||||
int childIndex)
|
||||
{
|
||||
if (edge == ChildEdge.Root)
|
||||
return;
|
||||
if ((uint)parentIndex >= (uint)nodes.Count)
|
||||
throw new InvalidDataException("Cell BSP child has no valid parent.");
|
||||
|
||||
MutableCellNode parent = nodes[parentIndex];
|
||||
if (edge == ChildEdge.Positive)
|
||||
parent.PositiveChildIndex = childIndex;
|
||||
else
|
||||
parent.NegativeChildIndex = childIndex;
|
||||
}
|
||||
|
||||
private enum ChildEdge : byte
|
||||
{
|
||||
Root,
|
||||
Positive,
|
||||
Negative,
|
||||
}
|
||||
|
||||
private readonly record struct PhysicsNodeFrame(
|
||||
PhysicsBSPNode Node,
|
||||
int ParentIndex,
|
||||
ChildEdge Edge);
|
||||
|
||||
private readonly record struct CellNodeFrame(
|
||||
CellBSPNode Node,
|
||||
int ParentIndex,
|
||||
ChildEdge Edge);
|
||||
|
||||
private sealed class MutablePhysicsNode
|
||||
{
|
||||
public MutablePhysicsNode(
|
||||
DatReaderWriter.Enums.BSPNodeType type,
|
||||
Plane splittingPlane,
|
||||
int leafIndex,
|
||||
int solid,
|
||||
FlatCollisionSphere boundingSphere,
|
||||
FlatIndexRange polygonIndexRange)
|
||||
{
|
||||
Type = type;
|
||||
SplittingPlane = splittingPlane;
|
||||
LeafIndex = leafIndex;
|
||||
Solid = solid;
|
||||
BoundingSphere = boundingSphere;
|
||||
PolygonIndexRange = polygonIndexRange;
|
||||
}
|
||||
|
||||
public DatReaderWriter.Enums.BSPNodeType Type { get; }
|
||||
|
||||
public Plane SplittingPlane { get; }
|
||||
|
||||
public int PositiveChildIndex { get; set; } = -1;
|
||||
|
||||
public int NegativeChildIndex { get; set; } = -1;
|
||||
|
||||
public int LeafIndex { get; }
|
||||
|
||||
public int Solid { get; }
|
||||
|
||||
public FlatCollisionSphere BoundingSphere { get; }
|
||||
|
||||
public FlatIndexRange PolygonIndexRange { get; }
|
||||
|
||||
public FlatPhysicsBspNode Freeze() => new(
|
||||
Type,
|
||||
SplittingPlane,
|
||||
PositiveChildIndex,
|
||||
NegativeChildIndex,
|
||||
LeafIndex,
|
||||
Solid,
|
||||
BoundingSphere,
|
||||
PolygonIndexRange);
|
||||
}
|
||||
|
||||
private sealed class MutableCellNode
|
||||
{
|
||||
public MutableCellNode(
|
||||
DatReaderWriter.Enums.BSPNodeType type,
|
||||
Plane splittingPlane,
|
||||
int leafIndex)
|
||||
{
|
||||
Type = type;
|
||||
SplittingPlane = splittingPlane;
|
||||
LeafIndex = leafIndex;
|
||||
}
|
||||
|
||||
public DatReaderWriter.Enums.BSPNodeType Type { get; }
|
||||
|
||||
public Plane SplittingPlane { get; }
|
||||
|
||||
public int PositiveChildIndex { get; set; } = -1;
|
||||
|
||||
public int NegativeChildIndex { get; set; } = -1;
|
||||
|
||||
public int LeafIndex { get; }
|
||||
|
||||
public FlatCellBspNode Freeze() => new(
|
||||
Type,
|
||||
SplittingPlane,
|
||||
PositiveChildIndex,
|
||||
NegativeChildIndex,
|
||||
LeafIndex);
|
||||
}
|
||||
|
||||
private sealed class ReferenceEqualityComparer<T> : IEqualityComparer<T>
|
||||
where T : class
|
||||
{
|
||||
public static ReferenceEqualityComparer<T> Instance { get; } = new();
|
||||
|
||||
public bool Equals(T? left, T? right) => ReferenceEquals(left, right);
|
||||
|
||||
public int GetHashCode(T value) => RuntimeHelpers.GetHashCode(value);
|
||||
}
|
||||
}
|
||||
565
src/AcDream.Core/Physics/FlatCollisionAssets.cs
Normal file
565
src/AcDream.Core/Physics/FlatCollisionAssets.cs
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// A checked half-open range into one immutable flat-asset array.
|
||||
/// </summary>
|
||||
public readonly record struct FlatIndexRange(int Start, int Count)
|
||||
{
|
||||
public int EndExclusive => checked(Start + Count);
|
||||
|
||||
internal void Validate(int length, string name)
|
||||
{
|
||||
if (Start < 0 || Count < 0 || Start > length - Count)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{name} range [{Start}, {Start}+{Count}) exceeds array length {length}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim DAT sphere value. No normalization or coordinate conversion is
|
||||
/// performed while preparing this record.
|
||||
/// </summary>
|
||||
public readonly record struct FlatCollisionSphere(Vector3 Origin, float Radius);
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim DAT cylinder-sphere value.
|
||||
/// </summary>
|
||||
public readonly record struct FlatCollisionCylinder(
|
||||
Vector3 Origin,
|
||||
float Radius,
|
||||
float Height);
|
||||
|
||||
/// <summary>
|
||||
/// One polygon row addressed through <see cref="VertexRange"/>.
|
||||
/// </summary>
|
||||
public readonly record struct FlatCollisionPolygon(
|
||||
ushort Id,
|
||||
Plane Plane,
|
||||
CullMode SidesType,
|
||||
int NumPoints,
|
||||
FlatIndexRange VertexRange);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable polygon rows plus their contiguous, source-ordered vertex stream.
|
||||
/// Rows are strictly ordered by polygon ID so package output does not depend on
|
||||
/// dictionary insertion or bucket order.
|
||||
/// </summary>
|
||||
public sealed class FlatPolygonTable
|
||||
{
|
||||
public static FlatPolygonTable Empty { get; } = new(
|
||||
ImmutableArray<FlatCollisionPolygon>.Empty,
|
||||
ImmutableArray<Vector3>.Empty);
|
||||
|
||||
public FlatPolygonTable(
|
||||
ImmutableArray<FlatCollisionPolygon> polygons,
|
||||
ImmutableArray<Vector3> vertices)
|
||||
{
|
||||
if (polygons.IsDefault)
|
||||
throw new ArgumentException("Polygon rows must not be default.", nameof(polygons));
|
||||
if (vertices.IsDefault)
|
||||
throw new ArgumentException("Vertex rows must not be default.", nameof(vertices));
|
||||
|
||||
ushort previousId = 0;
|
||||
for (int i = 0; i < polygons.Length; i++)
|
||||
{
|
||||
FlatCollisionPolygon polygon = polygons[i];
|
||||
polygon.VertexRange.Validate(vertices.Length, $"polygon[{i}].vertices");
|
||||
|
||||
if (polygon.NumPoints != polygon.VertexRange.Count)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"polygon[{i}] NumPoints {polygon.NumPoints} does not match " +
|
||||
$"its vertex count {polygon.VertexRange.Count}.");
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(polygon.SidesType))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"polygon[{i}] has unknown cull mode {(int)polygon.SidesType}.");
|
||||
}
|
||||
|
||||
if (i != 0 && polygon.Id <= previousId)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Flat polygon rows must be strictly ordered by unique ID.");
|
||||
}
|
||||
|
||||
previousId = polygon.Id;
|
||||
}
|
||||
|
||||
Polygons = polygons;
|
||||
Vertices = vertices;
|
||||
}
|
||||
|
||||
public ImmutableArray<FlatCollisionPolygon> Polygons { get; }
|
||||
|
||||
public ImmutableArray<Vector3> Vertices { get; }
|
||||
|
||||
public bool TryFindPolygonIndex(ushort id, out int index)
|
||||
{
|
||||
int low = 0;
|
||||
int high = Polygons.Length - 1;
|
||||
while (low <= high)
|
||||
{
|
||||
int middle = low + ((high - low) >> 1);
|
||||
ushort candidate = Polygons[middle].Id;
|
||||
if (candidate == id)
|
||||
{
|
||||
index = middle;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (candidate < id)
|
||||
low = middle + 1;
|
||||
else
|
||||
high = middle - 1;
|
||||
}
|
||||
|
||||
index = -1;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One physics BSP node. Child index <c>-1</c> means absent. Polygon indices
|
||||
/// address <see cref="FlatPhysicsBsp.PolygonIndexStream"/> through
|
||||
/// <see cref="PolygonIndexRange"/>.
|
||||
/// </summary>
|
||||
public readonly record struct FlatPhysicsBspNode(
|
||||
BSPNodeType Type,
|
||||
Plane SplittingPlane,
|
||||
int PositiveChildIndex,
|
||||
int NegativeChildIndex,
|
||||
int LeafIndex,
|
||||
int Solid,
|
||||
FlatCollisionSphere BoundingSphere,
|
||||
FlatIndexRange PolygonIndexRange);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable, deterministic pre-order representation of a retail physics BSP.
|
||||
/// It changes source storage only; traversal order and behavior remain defined
|
||||
/// by the graph oracle until Slice I6.
|
||||
/// </summary>
|
||||
public sealed class FlatPhysicsBsp
|
||||
{
|
||||
public FlatPhysicsBsp(
|
||||
int rootIndex,
|
||||
ImmutableArray<FlatPhysicsBspNode> nodes,
|
||||
ImmutableArray<int> polygonIndexStream,
|
||||
FlatPolygonTable polygonTable)
|
||||
{
|
||||
if (nodes.IsDefault)
|
||||
throw new ArgumentException("Node rows must not be default.", nameof(nodes));
|
||||
if (polygonIndexStream.IsDefault)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Polygon-index rows must not be default.",
|
||||
nameof(polygonIndexStream));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(polygonTable);
|
||||
ValidateTree(rootIndex, nodes, polygonIndexStream, polygonTable.Polygons.Length);
|
||||
|
||||
RootIndex = rootIndex;
|
||||
Nodes = nodes;
|
||||
PolygonIndexStream = polygonIndexStream;
|
||||
PolygonTable = polygonTable;
|
||||
}
|
||||
|
||||
public int RootIndex { get; }
|
||||
|
||||
public ImmutableArray<FlatPhysicsBspNode> Nodes { get; }
|
||||
|
||||
public ImmutableArray<int> PolygonIndexStream { get; }
|
||||
|
||||
public FlatPolygonTable PolygonTable { get; }
|
||||
|
||||
private static void ValidateTree(
|
||||
int rootIndex,
|
||||
ImmutableArray<FlatPhysicsBspNode> nodes,
|
||||
ImmutableArray<int> polygonIndexStream,
|
||||
int polygonCount)
|
||||
{
|
||||
if (nodes.Length == 0)
|
||||
{
|
||||
if (rootIndex != -1)
|
||||
throw new InvalidDataException("An empty physics BSP must use root index -1.");
|
||||
if (polygonIndexStream.Length != 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"An empty physics BSP cannot contain leaf polygon indices.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (rootIndex != 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"A deterministic pre-order physics BSP must start at index 0, not {rootIndex}.");
|
||||
}
|
||||
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
FlatPhysicsBspNode node = nodes[i];
|
||||
FlatBspNodeContract.Validate(
|
||||
node.Type,
|
||||
node.PositiveChildIndex >= 0,
|
||||
node.NegativeChildIndex >= 0,
|
||||
$"physics node[{i}]");
|
||||
ValidateChild(node.PositiveChildIndex, nodes.Length, i, "positive");
|
||||
ValidateChild(node.NegativeChildIndex, nodes.Length, i, "negative");
|
||||
node.PolygonIndexRange.Validate(
|
||||
polygonIndexStream.Length,
|
||||
$"physics node[{i}].polygonIndices");
|
||||
}
|
||||
|
||||
for (int i = 0; i < polygonIndexStream.Length; i++)
|
||||
{
|
||||
int polygonIndex = polygonIndexStream[i];
|
||||
if ((uint)polygonIndex >= (uint)polygonCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"leaf polygon stream[{i}] index {polygonIndex} exceeds " +
|
||||
$"polygon count {polygonCount}.");
|
||||
}
|
||||
}
|
||||
|
||||
ValidatePreOrderShape(
|
||||
rootIndex,
|
||||
nodes.Length,
|
||||
static (rows, index) => rows[index].PositiveChildIndex,
|
||||
static (rows, index) => rows[index].NegativeChildIndex,
|
||||
nodes,
|
||||
"physics BSP");
|
||||
}
|
||||
|
||||
private static void ValidateChild(
|
||||
int childIndex,
|
||||
int nodeCount,
|
||||
int ownerIndex,
|
||||
string edge)
|
||||
{
|
||||
if (childIndex < -1 || childIndex >= nodeCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"physics node[{ownerIndex}] {edge} child {childIndex} is out of range.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidatePreOrderShape<TNode>(
|
||||
int rootIndex,
|
||||
int nodeCount,
|
||||
Func<ImmutableArray<TNode>, int, int> getPositive,
|
||||
Func<ImmutableArray<TNode>, int, int> getNegative,
|
||||
ImmutableArray<TNode> nodes,
|
||||
string name)
|
||||
{
|
||||
var visited = new bool[nodeCount];
|
||||
var stack = new Stack<int>();
|
||||
stack.Push(rootIndex);
|
||||
int expectedIndex = 0;
|
||||
|
||||
while (stack.Count != 0)
|
||||
{
|
||||
int index = stack.Pop();
|
||||
if (visited[index])
|
||||
throw new InvalidDataException($"{name} contains a cycle or shared child.");
|
||||
if (index != expectedIndex)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{name} is not in deterministic positive-before-negative pre-order: " +
|
||||
$"expected node {expectedIndex}, encountered {index}.");
|
||||
}
|
||||
|
||||
visited[index] = true;
|
||||
expectedIndex++;
|
||||
|
||||
int negative = getNegative(nodes, index);
|
||||
int positive = getPositive(nodes, index);
|
||||
if (negative >= 0)
|
||||
stack.Push(negative);
|
||||
if (positive >= 0)
|
||||
stack.Push(positive);
|
||||
}
|
||||
|
||||
if (expectedIndex != nodeCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{name} contains {nodeCount - expectedIndex} unreachable node(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node in the separate cell-containment BSP.
|
||||
/// </summary>
|
||||
public readonly record struct FlatCellBspNode(
|
||||
BSPNodeType Type,
|
||||
Plane SplittingPlane,
|
||||
int PositiveChildIndex,
|
||||
int NegativeChildIndex,
|
||||
int LeafIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable deterministic representation of a cell-containment BSP. It is
|
||||
/// intentionally separate from physics BSP storage because it has no leaf
|
||||
/// polygons, solid marker, or bounding sphere.
|
||||
/// </summary>
|
||||
public sealed class FlatCellContainmentBsp
|
||||
{
|
||||
public FlatCellContainmentBsp(
|
||||
int rootIndex,
|
||||
ImmutableArray<FlatCellBspNode> nodes)
|
||||
{
|
||||
if (nodes.IsDefault)
|
||||
throw new ArgumentException("Node rows must not be default.", nameof(nodes));
|
||||
|
||||
if (nodes.Length == 0)
|
||||
{
|
||||
if (rootIndex != -1)
|
||||
throw new InvalidDataException("An empty cell BSP must use root index -1.");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (rootIndex != 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"A deterministic pre-order cell BSP must start at index 0, not {rootIndex}.");
|
||||
}
|
||||
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
FlatCellBspNode node = nodes[i];
|
||||
FlatBspNodeContract.Validate(
|
||||
node.Type,
|
||||
node.PositiveChildIndex >= 0,
|
||||
node.NegativeChildIndex >= 0,
|
||||
$"cell node[{i}]");
|
||||
if (node.PositiveChildIndex < -1 || node.PositiveChildIndex >= nodes.Length)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"cell node[{i}] positive child {node.PositiveChildIndex} is out of range.");
|
||||
}
|
||||
if (node.NegativeChildIndex < -1 || node.NegativeChildIndex >= nodes.Length)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"cell node[{i}] negative child {node.NegativeChildIndex} is out of range.");
|
||||
}
|
||||
}
|
||||
|
||||
FlatPhysicsBsp.ValidatePreOrderShape(
|
||||
rootIndex,
|
||||
nodes.Length,
|
||||
static (rows, index) => rows[index].PositiveChildIndex,
|
||||
static (rows, index) => rows[index].NegativeChildIndex,
|
||||
nodes,
|
||||
"cell-containment BSP");
|
||||
}
|
||||
|
||||
RootIndex = rootIndex;
|
||||
Nodes = nodes;
|
||||
}
|
||||
|
||||
public int RootIndex { get; }
|
||||
|
||||
public ImmutableArray<FlatCellBspNode> Nodes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable Setup collision payload. List order is the DAT order.
|
||||
/// </summary>
|
||||
public sealed class FlatSetupCollision
|
||||
{
|
||||
public FlatSetupCollision(
|
||||
ImmutableArray<FlatCollisionCylinder> cylinders,
|
||||
ImmutableArray<FlatCollisionSphere> spheres,
|
||||
float height,
|
||||
float radius,
|
||||
float stepUpHeight,
|
||||
float stepDownHeight)
|
||||
{
|
||||
if (cylinders.IsDefault)
|
||||
throw new ArgumentException("Cylinder rows must not be default.", nameof(cylinders));
|
||||
if (spheres.IsDefault)
|
||||
throw new ArgumentException("Sphere rows must not be default.", nameof(spheres));
|
||||
|
||||
Cylinders = cylinders;
|
||||
Spheres = spheres;
|
||||
Height = height;
|
||||
Radius = radius;
|
||||
StepUpHeight = stepUpHeight;
|
||||
StepDownHeight = stepDownHeight;
|
||||
}
|
||||
|
||||
public ImmutableArray<FlatCollisionCylinder> Cylinders { get; }
|
||||
|
||||
public ImmutableArray<FlatCollisionSphere> Spheres { get; }
|
||||
|
||||
public float Height { get; }
|
||||
|
||||
public float Radius { get; }
|
||||
|
||||
public float StepUpHeight { get; }
|
||||
|
||||
public float StepDownHeight { get; }
|
||||
}
|
||||
|
||||
public readonly record struct FlatGfxObjVisualBounds(
|
||||
Vector3 Min,
|
||||
Vector3 Max,
|
||||
Vector3 Center,
|
||||
float Radius,
|
||||
Vector3 HalfExtents);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable GfxObj collision payload prepared independently of rendering.
|
||||
/// </summary>
|
||||
public sealed record FlatGfxObjCollisionAsset(
|
||||
FlatPhysicsBsp PhysicsBsp,
|
||||
FlatCollisionSphere? BoundingSphere,
|
||||
FlatGfxObjVisualBounds? VisualBounds);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable CellStruct geometry. Per-EnvCell transforms and topology are not
|
||||
/// embedded so identical CellStruct geometry can be aliased safely.
|
||||
/// </summary>
|
||||
public sealed record FlatCellStructureCollisionAsset(
|
||||
FlatPhysicsBsp PhysicsBsp,
|
||||
FlatCellContainmentBsp ContainmentBsp,
|
||||
FlatPolygonTable PortalPolygons);
|
||||
|
||||
/// <summary>
|
||||
/// One per-EnvCell portal row. <see cref="PolygonIndex"/> directly addresses
|
||||
/// the owning <see cref="FlatCellStructureCollisionAsset.PortalPolygons"/>.
|
||||
/// </summary>
|
||||
public readonly record struct FlatEnvCellPortal(
|
||||
ushort OtherCellId,
|
||||
ushort PolygonId,
|
||||
ushort Flags,
|
||||
int PolygonIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Per-EnvCell topology kept separate from reusable CellStruct geometry.
|
||||
/// Visible cell IDs are sorted to make prepared output deterministic.
|
||||
/// </summary>
|
||||
public sealed class FlatEnvCellTopology
|
||||
{
|
||||
public FlatEnvCellTopology(
|
||||
ImmutableArray<FlatEnvCellPortal> portals,
|
||||
ImmutableArray<uint> visibleCellIds,
|
||||
bool seenOutside)
|
||||
{
|
||||
if (portals.IsDefault)
|
||||
throw new ArgumentException("Portal rows must not be default.", nameof(portals));
|
||||
if (visibleCellIds.IsDefault)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Visible-cell rows must not be default.",
|
||||
nameof(visibleCellIds));
|
||||
}
|
||||
|
||||
uint previous = 0;
|
||||
for (int i = 0; i < visibleCellIds.Length; i++)
|
||||
{
|
||||
uint current = visibleCellIds[i];
|
||||
if (i != 0 && current <= previous)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Visible cell IDs must be strictly ordered and unique.");
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
for (int i = 0; i < portals.Length; i++)
|
||||
{
|
||||
if (portals[i].PolygonIndex < 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"portal[{i}] has invalid polygon index {portals[i].PolygonIndex}.");
|
||||
}
|
||||
}
|
||||
|
||||
Portals = portals;
|
||||
VisibleCellIds = visibleCellIds;
|
||||
SeenOutside = seenOutside;
|
||||
}
|
||||
|
||||
public ImmutableArray<FlatEnvCellPortal> Portals { get; }
|
||||
|
||||
public ImmutableArray<uint> VisibleCellIds { get; }
|
||||
|
||||
public bool SeenOutside { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/shadow aggregate joining one reusable CellStruct with one EnvCell's
|
||||
/// topology. Production package/publication layers keep these lifetimes
|
||||
/// separate.
|
||||
/// </summary>
|
||||
public sealed class FlatCellCollisionAsset
|
||||
{
|
||||
public FlatCellCollisionAsset(
|
||||
FlatCellStructureCollisionAsset structure,
|
||||
FlatEnvCellTopology topology)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(structure);
|
||||
ArgumentNullException.ThrowIfNull(topology);
|
||||
|
||||
int polygonCount = structure.PortalPolygons.Polygons.Length;
|
||||
for (int i = 0; i < topology.Portals.Length; i++)
|
||||
{
|
||||
int polygonIndex = topology.Portals[i].PolygonIndex;
|
||||
if ((uint)polygonIndex >= (uint)polygonCount)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"portal[{i}] polygon index {polygonIndex} exceeds " +
|
||||
$"portal polygon count {polygonCount}.");
|
||||
}
|
||||
}
|
||||
|
||||
Structure = structure;
|
||||
Topology = topology;
|
||||
}
|
||||
|
||||
public FlatCellStructureCollisionAsset Structure { get; }
|
||||
|
||||
public FlatEnvCellTopology Topology { get; }
|
||||
}
|
||||
|
||||
internal static class FlatBspNodeContract
|
||||
{
|
||||
public static void Validate(
|
||||
BSPNodeType type,
|
||||
bool hasPositive,
|
||||
bool hasNegative,
|
||||
string name)
|
||||
{
|
||||
if (!Enum.IsDefined(type) || type == BSPNodeType.Portal)
|
||||
throw new InvalidDataException($"{name} has unsupported type 0x{(uint)type:X8}.");
|
||||
|
||||
(bool expectedPositive, bool expectedNegative) = type switch
|
||||
{
|
||||
BSPNodeType.Leaf => (false, false),
|
||||
BSPNodeType.BPIn or BSPNodeType.BPnn => (true, false),
|
||||
BSPNodeType.BpIN or BSPNodeType.BpnN => (false, true),
|
||||
BSPNodeType.BPIN or BSPNodeType.BPnN => (true, true),
|
||||
BSPNodeType.BPOL or BSPNodeType.BPFL => (false, false),
|
||||
_ => throw new InvalidDataException(
|
||||
$"{name} has unsupported type 0x{(uint)type:X8}."),
|
||||
};
|
||||
|
||||
if (hasPositive != expectedPositive || hasNegative != expectedNegative)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"{name} type {type} requires children " +
|
||||
$"(+={expectedPositive}, -={expectedNegative}) but contains " +
|
||||
$"(+={hasPositive}, -={hasNegative}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue