feat(content): bake and read flat collision assets

Append strict collision/topology payloads to the existing prepared package so later physics cutover can drop parsed DAT graphs without adding a second mapping or changing traversal behavior. The full 2,232,170-key catalog is deterministic across worker counts, exact-byte aliased, corruption-isolated, and cancellation-safe.
This commit is contained in:
Erik 2026-07-25 15:22:08 +02:00
parent d9300c7854
commit 9cd42417a8
29 changed files with 2868 additions and 63 deletions

View file

@ -163,17 +163,23 @@ fresh-vs-reused results and complete reset/reentrancy gates. Slice I2 is also
complete: immutable physics/containment BSP, polygon, Setup, bounds, and complete: immutable physics/containment BSP, polygon, Setup, bounds, and
EnvCell-topology records preserve source float bits and ordering across EnvCell-topology records preserve source float bits and ordering across
synthetic corruption gates and representative installed DATs without changing synthetic corruption gates and representative installed DATs without changing
production traversal. production traversal. Slice I3 is complete: bake-tool 4 appends typed GfxObj,
Setup, CellStruct, and EnvCell-topology collision payloads; the collision
interface shares the render package's single mmap. Two complete
29,908,271,024-byte bakes produced 2,232,170 typed keys, zero failures, and the
same SHA-256 across 16 and 7 workers. Corruption, cancellation, installed
package, Release build, and 8,371-test / 5-skip gates pass.
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 I3 from passes. Slice I is active at I4 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` and `docs/research/2026-07-25-slice-i1-transition-scratch.md` and
`docs/research/2026-07-25-slice-i2-flat-collision-schema.md`. `docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.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

@ -161,17 +161,23 @@ fresh-vs-reused results and complete reset/reentrancy gates. Slice I2 is also
complete: immutable physics/containment BSP, polygon, Setup, bounds, and complete: immutable physics/containment BSP, polygon, Setup, bounds, and
EnvCell-topology records preserve source float bits and ordering across EnvCell-topology records preserve source float bits and ordering across
synthetic corruption gates and representative installed DATs without changing synthetic corruption gates and representative installed DATs without changing
production traversal. production traversal. Slice I3 is complete: bake-tool 4 appends typed GfxObj,
Setup, CellStruct, and EnvCell-topology collision payloads; the collision
interface shares the render package's single mmap. Two complete
29,908,271,024-byte bakes produced 2,232,170 typed keys, zero failures, and the
same SHA-256 across 16 and 7 workers. Corruption, cancellation, installed
package, Release build, and 8,371-test / 5-skip gates pass.
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 I3 from passes. Slice I is active at I4 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` and `docs/research/2026-07-25-slice-i1-transition-scratch.md` and
`docs/research/2026-07-25-slice-i2-flat-collision-schema.md`. `docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.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

@ -173,6 +173,8 @@ src/
TerrainSurface.cs -> triangle-aware terrain contact (done) TerrainSurface.cs -> triangle-aware terrain contact (done)
BSPQuery.cs -> partial retail BSP dispatcher (active in L.2) BSPQuery.cs -> partial retail BSP dispatcher (active in L.2)
TransitionTypes.cs -> SpherePath / CollisionInfo / transition helpers (active in L.2) TransitionTypes.cs -> SpherePath / CollisionInfo / transition helpers (active in L.2)
FlatCollisionAssets.cs -> immutable array/index collision schema (Slice I2)
FlatCollisionAssetBuilder.cs -> deterministic DAT graph preparation (Slice I2/I3)
PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active) PhysicsDataCache.cs -> GfxObj / Setup / CellStruct collision data (done, active)
ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active) ShadowObjectRegistry.cs -> broadphase for nearby physics objects (active)
PhysicsEngine.cs -> ResolveWithTransition active player path PhysicsEngine.cs -> ResolveWithTransition active player path

View file

@ -120,8 +120,9 @@ EnvCell, Surface, palette, and texture graphs during portals. The validated
machine-local `acdream.pak` is opened through Content's machine-local `acdream.pak` is opened through Content's
`IPreparedAssetSource`; typed GfxObj and EnvCell requests deserialize immutable `IPreparedAssetSource`; typed GfxObj and EnvCell requests deserialize immutable
`ObjectMeshData` while retaining the existing App worker, staging, render-thread `ObjectMeshData` while retaining the existing App worker, staging, render-thread
upload, cache, ownership, and shutdown contracts. The format-1/bake-tool-3 upload, cache, ownership, and shutdown contracts. The original
payload persists exact batch translucency so App does not reconstruct a format-1/bake-tool-3 render payload persists exact batch translucency so App
does not reconstruct a
`GfxObjMesh` for metadata. Setup activation uses the package TOC as an explicit `GfxObjMesh` for metadata. Setup activation uses the package TOC as an explicit
type-presence index before reading valid Setup records through the bounded DAT type-presence index before reading valid Setup records through the bounded DAT
cache. `DatPreparedAssetSource` and `MeshExtractor` remain explicit cache. `DatPreparedAssetSource` and `MeshExtractor` remain explicit
@ -131,6 +132,18 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes
installed-DAT gates are recorded in installed-DAT gates are recorded in
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`. `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
**Slice I3 prepared collision extension (2026-07-25).** The package remains
format 1 and retains mesh type values 13; bake-tool 4 appends typed GfxObj,
Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the
immutable flat records and deterministic raw-DAT flattener. Content owns the
strict little-endian codec and `IPreparedCollisionSource`.
`PakPreparedAssetSource` implements the render and collision interfaces over
one mmap; it does not create a second DAT reader or mapping. CellStruct
payloads alias only after exact serialized-byte comparison, while each
EnvCell topology remains independently keyed. Production traversal is still
the parsed graph oracle until Slice I6. Full-catalog evidence:
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md`.
**Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter **Retail VFX hook compatibility seam (2026-07-14).** Chorizite.DatReaderWriter
2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while 2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while
retail inherits the complete `CreateParticleHook` payload. The narrow readers in retail inherits the complete `CreateParticleHook` payload. The narrow readers in

View file

@ -1660,7 +1660,7 @@ port in any phase — no separate listing here.
> `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 I3 from > connected lifecycle/reconnect gate. Slice I is active at I4 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
@ -1685,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; immutable flat collision storage is defined and package integration continues in Slice I3. | | 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 and its complete deterministic prepared package are shipped; traversal differential work continues in Slice I4. |
| 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 and I2 immutable flat collision schema shipped; I3 package serialization/bake is active. | | MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch, I2 immutable schema, and I3 complete deterministic collision package shipped; I4 flat traversal shadow implementation is active. |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
--- ---

View file

@ -123,9 +123,13 @@ deterministic immutable physics/containment BSP, polygon, Setup, bounds, and
EnvCell-topology records without cutting traversal over. Synthetic corruption EnvCell-topology records without cutting traversal over. Synthetic corruption
gates and representative installed-DAT source identity pass bit-for-bit. The 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 complete I2 Release gate is 3,826 App tests / 3 skips and 8,355 solution tests
/ 5 skips. / 5 skips. I3 appends four collision package types and a separate typed
prepared-collision interface over the existing single mmap. Two complete
29,908,271,024-byte bakes contain 2,232,170 keys with zero failures and the
same SHA-256 across different worker counts; cancellation/corruption gates and
8,371 solution tests / 5 skips pass.
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 I3 under and pooled/borrowed/direct network I/O pass. Slice I is active at I4 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 I0I2 are complete. Slices FL were explicitly **Status:** Slices AH and I0I3 are complete. Slices FL were explicitly
approved 2026-07-24; Slice I3 is active. approved 2026-07-24; Slice I4 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.
@ -1200,8 +1200,10 @@ grounded walkable-publication profiles each measure 0 B/resolve, while
fresh-vs-reused output and body state remain bit-identical. I2 now supplies fresh-vs-reused output and body state remain bit-identical. I2 now supplies
validated immutable flat collision records: synthetic and installed-DAT validated immutable flat collision records: synthetic and installed-DAT
source identity, exact float bits, deterministic pre-order, and corruption source identity, exact float bits, deterministic pre-order, and corruption
tripwires pass without changing production traversal. I3 package tripwires pass without changing production traversal. I3 now packages all
serialization and complete-catalog baking are the current execution point. flat collision/topology records through a shared typed mmap source: two
complete 2,232,170-key bakes are byte-identical with zero failures. I4 flat
traversal shadow implementation is the current execution point.
Evidence: 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
@ -1209,6 +1211,8 @@ and
and and
[`../research/2026-07-25-slice-i2-flat-collision-schema.md`](../research/2026-07-25-slice-i2-flat-collision-schema.md) [`../research/2026-07-25-slice-i2-flat-collision-schema.md`](../research/2026-07-25-slice-i2-flat-collision-schema.md)
and and
[`../research/2026-07-25-slice-i3-prepared-collision-package.md`](../research/2026-07-25-slice-i3-prepared-collision-package.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:
@ -1222,7 +1226,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 I3) -> flat collision assets (I — CURRENT at I4)
-> 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,8 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics # Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0I2 COMPLETE — retail oracle, zero-allocation transition **Status:** I0I3 COMPLETE — retail oracle, zero-allocation transition
scratch, and deterministic immutable collision records fixed; I3 active scratch, deterministic immutable collision records, and complete prepared
package fixed; I4 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
@ -176,7 +177,12 @@ Evidence:
Gate: two independent bakes are byte-identical; every collision key required Gate: two independent bakes are byte-identical; every collision key required
by the canonical route is present; a failed/cancelled bake cannot replace the by the canonical route is present; a failed/cancelled bake cannot replace the
last good pak. last good pak. **PASSED 2026-07-25:** two complete 29,908,271,024-byte bakes
with different worker counts produced the same SHA-256 and 2,232,170 typed
keys with zero failures. Strict serialization/corruption/cancellation gates,
representative installed-package reads, Release build, and 8,371 solution
tests / 5 skips pass. Production traversal remains graph-only. Evidence:
[`../research/2026-07-25-slice-i3-prepared-collision-package.md`](../research/2026-07-25-slice-i3-prepared-collision-package.md).
### I4 — flat traversal shadow implementation ### I4 — flat traversal shadow implementation

View file

@ -0,0 +1,97 @@
# Slice I3 — prepared flat-collision package
**Date:** 2026-07-25
**Scope:** Modern Runtime Slice I3 only
**Behavioral boundary:** storage, serialization, bake, and prepared lookup;
production collision traversal remains on the parsed-DAT graph oracle.
## Result
The existing format-1 `acdream.pak` now carries four appended asset types:
| Numeric type | Payload |
|---:|---|
| 4 | GfxObj collision BSP, polygon table, bounding sphere, and visual bounds |
| 5 | Setup cylinders, spheres, and movement dimensions |
| 6 | reusable CellStruct physics BSP, containment BSP, and portal polygons |
| 7 | independently keyed EnvCell portals, visibility, and `seen_outside` |
The existing mesh type values 13 did not change. Bake-tool identity advanced
from 3 to 4; the package binary container remains format 1.
`FlatCollisionAssetSerializer` is a strict little-endian codec. It:
- writes every `float` through `SingleToInt32Bits`;
- retains node, polygon, vertex, Setup-shape, portal, and visible-cell order;
- checks counts before allocation and validates combined byte requirements;
- checks cancellation during large rows;
- rejects wrong payload kinds, unsupported schema versions, non-zero reserved
bits, invalid enum/tree/range contracts, truncation, and trailing bytes;
- remains behind the package entry CRC, so a CRC or structural fault marks
only that typed entry corrupt.
`IPreparedCollisionSource` is separate from the render-only
`IPreparedAssetSource` API. `PakPreparedAssetSource` implements both over the
same `PakReader` and therefore maps the 29.9 GB immutable package once, not
once per subsystem. Render and collision counters are independent.
CellStruct collision geometry is addressed by every EnvCell source key. The
bake emits ordinary TOC aliases within the same source group and across groups
only after comparing the complete serialized payload bytes. Per-cell topology
is always written under its own key and is never inferred from shared render
geometry.
## Complete installed-DAT bake
Two independent full bakes used 16 and 7 workers:
| Metric | Result |
|---|---:|
| GfxObj mesh keys | 15,318 |
| Setup mesh keys | 5,935 |
| EnvCell mesh keys | 729,888 |
| GfxObj collision keys | 15,318 |
| Setup collision keys | 5,935 |
| CellStruct collision keys | 729,888 |
| EnvCell topology keys | 729,888 |
| Total typed keys | 2,232,170 |
| Physical blobs | 781,196 |
| Failures | 0 |
| File bytes | 29,908,271,024 |
| SHA-256 | `FEE8595D512DCB119C98B3D9911CDF9A905C97B624BCB826CEAF8DFD3F67269C` |
Both complete files have identical length and SHA-256. Exact-byte
deduplication produced:
- 9,363 physical GfxObj collision blobs behind 15,318 keys;
- 979 physical Setup collision blobs behind 5,935 keys;
- 2,596 physical CellStruct collision blobs behind 729,888 keys.
The 16-worker bake completed and validated in 107.5 seconds; the 7-worker bake
completed and validated in 80.5 seconds. Peak bake-process memory was
4.434.89 GB working set and 3.814.21 GB private. This is offline extraction
state and is not mapped as committed client memory; the production source
memory-maps the immutable file and pages only accessed ranges.
The validated bake-tool-4 package was installed at the machine-local DAT
location for subsequent Slice I gates.
## Verification
- strict codec round-trip and exact-float-bit tests for all four payloads;
- every truncation of a representative payload rejected;
- wrong-kind, negative/impossible count, trailing-byte, CRC, and valid-CRC
structural-corruption tests;
- concurrent prepared reads and separate render/collision accounting;
- raw-DAT versus current cached-source preparation on representative installed
GfxObjs, Setups, and EnvCells;
- filtered 1/8- and 8/3-worker bake equivalence and exact alias tests;
- complete installed package reads for representative GfxObj, Setup,
CellStruct, and EnvCell topology keys;
- failed validation and timed cancellation preserve the last published file;
- Release build: zero warnings;
- complete Release suite: 8,371 passed / 5 skipped.
No divergence-register row changes: no gameplay decision or traversal route
changed. Slice I4 adds the flat traversal referee while the graph path remains
authoritative.

View file

@ -10,6 +10,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<!-- NO Silk.NET / GL packages here — ever. acdream-bake is an offline CLI <!-- NO Silk.NET / GL packages here — ever. acdream-bake is an offline CLI
that drives the GL-free AcDream.Content.MeshExtractor; it must not that drives the GL-free AcDream.Content.MeshExtractor; it must not

View file

@ -8,7 +8,8 @@ public static class BakeArtifactValidator
public static void Validate( public static void Validate(
string path, string path,
in PakHeader expectedHeader, in PakHeader expectedHeader,
int expectedTocCount) int expectedTocCount,
IReadOnlyDictionary<PakAssetType, int>? expectedTypeCounts = null)
{ {
using var reader = new PakReader(path); using var reader = new PakReader(path);
var actual = reader.Header; var actual = reader.Header;
@ -35,5 +36,18 @@ public static class BakeArtifactValidator
$"{expectedTocCount}"); $"{expectedTocCount}");
reader.ValidateTocStructure(); reader.ValidateTocStructure();
if (expectedTypeCounts is null)
return;
foreach ((PakAssetType type, int expectedCount) in expectedTypeCounts)
{
int actualCount = reader.CountEntries(type);
if (actualCount != expectedCount)
{
throw new InvalidDataException(
$"bake catalog type {type} contains {actualCount} keys; " +
$"expected {expectedCount}");
}
}
} }
} }

View file

@ -3,9 +3,12 @@ using System.Diagnostics;
using System.Numerics; using System.Numerics;
using AcDream.Content; using AcDream.Content;
using AcDream.Content.Pak; using AcDream.Content.Pak;
using AcDream.Core.Physics;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options; using DatReaderWriter.Options;
using DatReaderWriter.Types;
using DatEnvironment = DatReaderWriter.DBObjs.Environment; using DatEnvironment = DatReaderWriter.DBObjs.Environment;
namespace AcDream.Bake; namespace AcDream.Bake;
@ -30,6 +33,16 @@ public sealed record BakeReport
public required int EnvCellKeys { get; init; } public required int EnvCellKeys { get; init; }
public required int UniqueEnvCellGeometries { get; init; } public required int UniqueEnvCellGeometries { get; init; }
public required int EnvCellAliases { get; init; } public required int EnvCellAliases { get; init; }
public required int GfxObjCollisionKeys { get; init; }
public required int UniqueGfxObjCollisions { get; init; }
public required int GfxObjCollisionAliases { get; init; }
public required int SetupCollisionKeys { get; init; }
public required int UniqueSetupCollisions { get; init; }
public required int SetupCollisionAliases { get; init; }
public required int CellStructureCollisionKeys { get; init; }
public required int UniqueCellStructureCollisions { get; init; }
public required int CellStructureCollisionAliases { get; init; }
public required int EnvCellTopologyKeys { get; init; }
public required int SideStagedKeys { get; init; } public required int SideStagedKeys { get; init; }
public int SideStagedDuplicateKeys { get; init; } public int SideStagedDuplicateKeys { get; init; }
public required int PhysicalBlobs { get; init; } public required int PhysicalBlobs { get; init; }
@ -40,6 +53,11 @@ public sealed record BakeReport
public required long OutputBytes { get; init; } public required long OutputBytes { get; init; }
public required long PeakWorkingSetBytes { get; init; } public required long PeakWorkingSetBytes { get; init; }
public required long PeakPrivateBytes { get; init; } public required long PeakPrivateBytes { get; init; }
public required IReadOnlyDictionary<PakAssetType, int> TypeCounts
{
get;
init;
}
public double EnvCellDedupRatio => public double EnvCellDedupRatio =>
UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries; UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries;
@ -80,7 +98,8 @@ public static class BakeRunner
(temporaryPath, result) => BakeArtifactValidator.Validate( (temporaryPath, result) => BakeArtifactValidator.Validate(
temporaryPath, temporaryPath,
result.Header, result.Header,
result.TotalKeys), result.TotalKeys,
result.TypeCounts),
options.CancellationToken); options.CancellationToken);
totalStopwatch.Stop(); totalStopwatch.Stop();
@ -371,9 +390,402 @@ public static class BakeRunner
sideStagedWritten++; sideStagedWritten++;
} }
// ---- immutable flat collision payloads ------------------------
uint[] collisionGfxIds = gfxObjIds
.Concat(
sideStagedByKey.Keys.Select(
key => PakKey.Decompose(key).FileId))
.Distinct()
.Order()
.ToArray();
var collisionWork =
new List<(ulong Key, PakAssetType Type, uint FileId)>(
collisionGfxIds.Length + setupIds.Count);
collisionWork.AddRange(
collisionGfxIds.Select(
id => (
PakKey.Compose(PakAssetType.GfxObjCollision, id),
PakAssetType.GfxObjCollision,
id)));
collisionWork.AddRange(
setupIds.Select(
id => (
PakKey.Compose(PakAssetType.SetupCollision, id),
PakAssetType.SetupCollision,
id)));
collisionWork.Sort((left, right) => left.Key.CompareTo(right.Key));
var gfxCollisionAliases = new ExactPayloadAliasCatalog();
var setupCollisionAliases = new ExactPayloadAliasCatalog();
var cellStructureAliases = new ExactPayloadAliasCatalog();
int gfxCollisionWritten = 0;
int uniqueGfxCollisions = 0;
int gfxCollisionAliasCount = 0;
int setupCollisionWritten = 0;
int uniqueSetupCollisions = 0;
int setupCollisionAliasCount = 0;
int cellStructureWritten = 0;
int uniqueCellStructures = 0;
int cellStructureAliasCount = 0;
int envCellTopologyWritten = 0;
long collisionCompleted = 0;
int totalCollisionJobs =
collisionWork.Count
+ envCatalog.UniqueGeometryCount
+ envCatalog.CellCount;
var collisionStopwatch = Stopwatch.StartNew();
lastProgressReport.Restart();
Console.WriteLine();
Console.WriteLine(
$"collision catalog: {collisionGfxIds.Length:N0} GfxObj, " +
$"{setupIds.Count:N0} Setup, " +
$"{envCatalog.UniqueGeometryCount:N0} CellStruct source groups, " +
$"{envCatalog.CellCount:N0} EnvCell topology records");
for (int batchStart = 0;
batchStart < collisionWork.Count;
batchStart += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
var batch = collisionWork
.Skip(batchStart)
.Take(BatchSize)
.ToArray();
var batchResults =
new ConcurrentBag<CollisionPayloadResult>();
Parallel.ForEach(
batch,
new ParallelOptions
{
MaxDegreeOfParallelism = options.Threads,
CancellationToken = cancellationToken,
},
item =>
{
var (key, type, fileId) = item;
try
{
byte[] payload;
if (type == PakAssetType.GfxObjCollision)
{
if (!datReaderWriter.Portal.TryGet<GfxObj>(
fileId,
out GfxObj? gfxObj)
|| gfxObj is null)
{
failures.Add((
type,
fileId,
"GfxObj DAT record was not found"));
return;
}
FlatGfxObjCollisionAsset asset =
FlatCollisionAssetBuilder.FlattenGfxObj(
gfxObj);
payload = FlatCollisionAssetSerializer.Serialize(
asset,
cancellationToken);
}
else
{
if (!datReaderWriter.Portal.TryGet<Setup>(
fileId,
out Setup? setup)
|| setup is null)
{
failures.Add((
type,
fileId,
"Setup DAT record was not found"));
return;
}
FlatSetupCollision asset =
FlatCollisionAssetBuilder.FlattenSetup(
setup);
payload = FlatCollisionAssetSerializer.Serialize(
asset,
cancellationToken);
}
batchResults.Add(
new CollisionPayloadResult(
key,
type,
fileId,
payload));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
failures.Add((type, fileId, exception.Message));
}
finally
{
Interlocked.Increment(ref collisionCompleted);
}
});
foreach (CollisionPayloadResult result in
batchResults.OrderBy(result => result.Key))
{
ExactPayloadAliasCatalog aliases =
result.Type == PakAssetType.GfxObjCollision
? gfxCollisionAliases
: setupCollisionAliases;
bool alias = WriteExactPayload(
writer,
result.Key,
result.Payload,
aliases);
writtenKeys.Add(result.Key);
if (result.Type == PakAssetType.GfxObjCollision)
{
gfxCollisionWritten++;
if (alias)
gfxCollisionAliasCount++;
else
uniqueGfxCollisions++;
}
else
{
setupCollisionWritten++;
if (alias)
setupCollisionAliasCount++;
else
uniqueSetupCollisions++;
}
}
ReportProgressIfDue(
collisionCompleted,
totalCollisionJobs,
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
final: false);
}
for (int groupBatchStart = 0;
groupBatchStart < envCatalog.Groups.Count;
groupBatchStart += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
EnvCellGeometryGroup[] groupBatch = envCatalog.Groups
.Skip(groupBatchStart)
.Take(BatchSize)
.ToArray();
var structureResults =
new ConcurrentBag<CellStructurePayloadResult>();
Parallel.ForEach(
groupBatch,
new ParallelOptions
{
MaxDegreeOfParallelism = options.Threads,
CancellationToken = cancellationToken,
},
group =>
{
try
{
uint environmentDid =
0x0D00_0000u | group.EnvironmentId;
if (!datReaderWriter.Portal.TryGet<DatEnvironment>(
environmentDid,
out DatEnvironment? environment)
|| environment is null
|| !environment.Cells.TryGetValue(
group.CellStructure,
out CellStruct? cellStruct)
|| cellStruct is null)
{
failures.Add((
PakAssetType.CellStructureCollision,
group.FileIds[0],
$"environment 0x{environmentDid:X8} cell " +
$"structure {group.CellStructure} was not found"));
Interlocked.Add(
ref collisionCompleted,
group.FileIds.Length);
return;
}
FlatCellStructureCollisionAsset structure =
FlatCollisionAssetBuilder.FlattenCellStructure(
cellStruct);
byte[] payload =
FlatCollisionAssetSerializer.Serialize(
structure,
cancellationToken);
structureResults.Add(
new CellStructurePayloadResult(
group,
structure,
payload));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
failures.Add((
PakAssetType.CellStructureCollision,
group.FileIds[0],
exception.Message));
Interlocked.Add(
ref collisionCompleted,
group.FileIds.Length);
}
finally
{
Interlocked.Increment(ref collisionCompleted);
}
});
foreach (CellStructurePayloadResult result in
structureResults.OrderBy(
result => result.Group.FileIds[0]))
{
(int physical, int aliases) = WriteExactPayloadGroup(
writer,
PakAssetType.CellStructureCollision,
result.Group.FileIds,
result.Payload,
cellStructureAliases,
writtenKeys);
cellStructureWritten += result.Group.FileIds.Length;
uniqueCellStructures += physical;
cellStructureAliasCount += aliases;
for (int topologyBatchStart = 0;
topologyBatchStart < result.Group.FileIds.Length;
topologyBatchStart += BatchSize)
{
cancellationToken.ThrowIfCancellationRequested();
uint[] topologyBatch = result.Group.FileIds
.Skip(topologyBatchStart)
.Take(BatchSize)
.ToArray();
var topologyResults =
new ConcurrentBag<CollisionPayloadResult>();
Parallel.ForEach(
topologyBatch,
new ParallelOptions
{
MaxDegreeOfParallelism = options.Threads,
CancellationToken = cancellationToken,
},
fileId =>
{
try
{
if (!datReaderWriter.Cell.TryGet<EnvCell>(
fileId,
out EnvCell? envCell)
|| envCell is null)
{
failures.Add((
PakAssetType.EnvCellTopology,
fileId,
"EnvCell DAT record was not found"));
return;
}
FlatEnvCellTopology topology =
FlatCollisionAssetBuilder
.FlattenEnvCellTopology(
fileId,
envCell,
result.Structure
.PortalPolygons);
byte[] payload =
FlatCollisionAssetSerializer.Serialize(
topology,
cancellationToken);
topologyResults.Add(
new CollisionPayloadResult(
PakKey.Compose(
PakAssetType.EnvCellTopology,
fileId),
PakAssetType.EnvCellTopology,
fileId,
payload));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
failures.Add((
PakAssetType.EnvCellTopology,
fileId,
exception.Message));
}
finally
{
Interlocked.Increment(
ref collisionCompleted);
}
});
foreach (CollisionPayloadResult topology in
topologyResults.OrderBy(
result => result.Key))
{
writer.AddBlob(
topology.Key,
topology.Payload);
writtenKeys.Add(topology.Key);
envCellTopologyWritten++;
}
ReportProgressIfDue(
collisionCompleted,
totalCollisionJobs,
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
final: false);
}
}
}
collisionStopwatch.Stop();
ReportProgressIfDue(
collisionCompleted,
totalCollisionJobs,
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
final: true);
writer.Finish(); writer.Finish();
stopwatch.Stop(); stopwatch.Stop();
var typeCounts = new Dictionary<PakAssetType, int>
{
[PakAssetType.GfxObjMesh] =
gfxObjWritten + sideStagedWritten,
[PakAssetType.SetupMesh] = setupWritten,
[PakAssetType.EnvCellMesh] = envCellKeysWritten,
[PakAssetType.GfxObjCollision] = gfxCollisionWritten,
[PakAssetType.SetupCollision] = setupCollisionWritten,
[PakAssetType.CellStructureCollision] =
cellStructureWritten,
[PakAssetType.EnvCellTopology] = envCellTopologyWritten,
};
var report = new BakeReport var report = new BakeReport
{ {
Header = header, Header = header,
@ -382,9 +794,27 @@ public static class BakeRunner
EnvCellKeys = envCellKeysWritten, EnvCellKeys = envCellKeysWritten,
UniqueEnvCellGeometries = envCellGeometriesWritten, UniqueEnvCellGeometries = envCellGeometriesWritten,
EnvCellAliases = envCellKeysWritten - envCellGeometriesWritten, EnvCellAliases = envCellKeysWritten - envCellGeometriesWritten,
GfxObjCollisionKeys = gfxCollisionWritten,
UniqueGfxObjCollisions = uniqueGfxCollisions,
GfxObjCollisionAliases = gfxCollisionAliasCount,
SetupCollisionKeys = setupCollisionWritten,
UniqueSetupCollisions = uniqueSetupCollisions,
SetupCollisionAliases = setupCollisionAliasCount,
CellStructureCollisionKeys = cellStructureWritten,
UniqueCellStructureCollisions = uniqueCellStructures,
CellStructureCollisionAliases =
cellStructureAliasCount,
EnvCellTopologyKeys = envCellTopologyWritten,
SideStagedKeys = sideStagedWritten, SideStagedKeys = sideStagedWritten,
PhysicalBlobs = PhysicalBlobs =
gfxObjWritten + setupWritten + envCellGeometriesWritten + sideStagedWritten, gfxObjWritten
+ setupWritten
+ envCellGeometriesWritten
+ sideStagedWritten
+ uniqueGfxCollisions
+ uniqueSetupCollisions
+ uniqueCellStructures
+ envCellTopologyWritten,
TotalKeys = writtenKeys.Count, TotalKeys = writtenKeys.Count,
Failures = failures.Count, Failures = failures.Count,
ExtractionAndWriteElapsed = stopwatch.Elapsed, ExtractionAndWriteElapsed = stopwatch.Elapsed,
@ -392,6 +822,7 @@ public static class BakeRunner
OutputBytes = new FileInfo(options.OutPath).Length, OutputBytes = new FileInfo(options.OutPath).Length,
PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64, PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64,
PeakPrivateBytes = Process.GetCurrentProcess().PeakPagedMemorySize64, PeakPrivateBytes = Process.GetCurrentProcess().PeakPagedMemorySize64,
TypeCounts = typeCounts,
}; };
report = report with { SideStagedDuplicateKeys = sideStagedDuped }; report = report with { SideStagedDuplicateKeys = sideStagedDuped };
@ -400,6 +831,63 @@ public static class BakeRunner
} }
} }
private static bool WriteExactPayload(
PakWriter writer,
ulong key,
byte[] payload,
ExactPayloadAliasCatalog aliases)
{
if (aliases.TryFind(payload, out ulong primaryKey))
{
writer.AddAlias(key, primaryKey);
return true;
}
writer.AddBlob(key, payload);
aliases.Add(key, payload);
return false;
}
private static (int Physical, int Aliases) WriteExactPayloadGroup(
PakWriter writer,
PakAssetType type,
IReadOnlyList<uint> fileIds,
byte[] payload,
ExactPayloadAliasCatalog aliases,
HashSet<ulong> writtenKeys)
{
if (fileIds.Count == 0)
throw new InvalidDataException("collision alias group is empty");
int firstUnwrittenIndex = 0;
ulong primaryKey;
int physical;
if (aliases.TryFind(payload, out primaryKey))
{
physical = 0;
}
else
{
primaryKey = PakKey.Compose(type, fileIds[0]);
writer.AddBlob(primaryKey, payload);
aliases.Add(primaryKey, payload);
writtenKeys.Add(primaryKey);
firstUnwrittenIndex = 1;
physical = 1;
}
int aliasCount = 0;
for (int i = firstUnwrittenIndex; i < fileIds.Count; i++)
{
ulong aliasKey = PakKey.Compose(type, fileIds[i]);
writer.AddAlias(aliasKey, primaryKey);
writtenKeys.Add(aliasKey);
aliasCount++;
}
return (physical, aliasCount);
}
private static int DrainSideStaged( private static int DrainSideStaged(
ConcurrentQueue<ObjectMeshData> sideStaged, ConcurrentQueue<ObjectMeshData> sideStaged,
Dictionary<ulong, ObjectMeshData> sideStagedByKey, Dictionary<ulong, ObjectMeshData> sideStagedByKey,
@ -450,6 +938,20 @@ public static class BakeRunner
Console.WriteLine($" EnvCell unique: {report.UniqueEnvCellGeometries:N0}"); Console.WriteLine($" EnvCell unique: {report.UniqueEnvCellGeometries:N0}");
Console.WriteLine($" EnvCell aliases: {report.EnvCellAliases:N0}"); Console.WriteLine($" EnvCell aliases: {report.EnvCellAliases:N0}");
Console.WriteLine($" EnvCell dedup: {report.EnvCellDedupRatio:F1}x"); Console.WriteLine($" EnvCell dedup: {report.EnvCellDedupRatio:F1}x");
Console.WriteLine(
$" Gfx collisions: {report.GfxObjCollisionKeys:N0} keys, " +
$"{report.UniqueGfxObjCollisions:N0} blobs, " +
$"{report.GfxObjCollisionAliases:N0} aliases");
Console.WriteLine(
$" Setup collisions: {report.SetupCollisionKeys:N0} keys, " +
$"{report.UniqueSetupCollisions:N0} blobs, " +
$"{report.SetupCollisionAliases:N0} aliases");
Console.WriteLine(
$" Cell structures: {report.CellStructureCollisionKeys:N0} keys, " +
$"{report.UniqueCellStructureCollisions:N0} blobs, " +
$"{report.CellStructureCollisionAliases:N0} aliases");
Console.WriteLine(
$" Cell topologies: {report.EnvCellTopologyKeys:N0}");
Console.WriteLine( Console.WriteLine(
$" side-staged keys: {report.SideStagedKeys:N0} " + $" side-staged keys: {report.SideStagedKeys:N0} " +
$"({report.SideStagedDuplicateKeys:N0} duplicate keys)"); $"({report.SideStagedDuplicateKeys:N0} duplicate keys)");
@ -526,4 +1028,15 @@ public static class BakeRunner
return envCellIds; return envCellIds;
} }
private sealed record CollisionPayloadResult(
ulong Key,
PakAssetType Type,
uint FileId,
byte[] Payload);
private sealed record CellStructurePayloadResult(
EnvCellGeometryGroup Group,
FlatCellStructureCollisionAsset Structure,
byte[] Payload);
} }

View file

@ -0,0 +1,50 @@
using System.Security.Cryptography;
namespace AcDream.Bake;
/// <summary>
/// Offline-only exact-byte deduplication catalog. SHA-256 narrows candidates;
/// a complete byte comparison is still mandatory before any alias is emitted.
/// </summary>
internal sealed class ExactPayloadAliasCatalog
{
private readonly Dictionary<string, List<Entry>> _entriesByDigest =
new(StringComparer.Ordinal);
public bool TryFind(ReadOnlySpan<byte> payload, out ulong primaryKey)
{
string digest = Digest(payload);
if (_entriesByDigest.TryGetValue(digest, out List<Entry>? candidates))
{
foreach (Entry candidate in candidates)
{
if (payload.SequenceEqual(candidate.Payload))
{
primaryKey = candidate.PrimaryKey;
return true;
}
}
}
primaryKey = 0;
return false;
}
public void Add(ulong primaryKey, byte[] payload)
{
ArgumentNullException.ThrowIfNull(payload);
string digest = Digest(payload);
if (!_entriesByDigest.TryGetValue(digest, out List<Entry>? entries))
{
entries = [];
_entriesByDigest.Add(digest, entries);
}
entries.Add(new Entry(primaryKey, payload));
}
private static string Digest(ReadOnlySpan<byte> payload) =>
Convert.ToHexString(SHA256.HashData(payload));
private sealed record Entry(ulong PrimaryKey, byte[] Payload);
}

View file

@ -0,0 +1,715 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
namespace AcDream.Content;
/// <summary>
/// Strict little-endian package codec for immutable flat collision assets.
/// Every float is persisted through its exact IEEE-754 bit pattern. Readers
/// reject invalid counts, invalid enum/range/tree contracts, wrong payload
/// kinds, non-zero reserved bytes, truncation, and trailing bytes.
/// </summary>
public static class FlatCollisionAssetSerializer
{
private const uint Magic = 0x4C_43_43_41u; // "ACCL" little-endian
private const byte SchemaVersion = 1;
private const int MaximumRows = 16_777_216;
public static byte[] Serialize(
FlatGfxObjCollisionAsset asset,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(asset);
var writer = new CollisionWriter();
writer.WriteHeader(CollisionPayloadKind.GfxObj);
WritePhysicsBsp(writer, asset.PhysicsBsp, cancellationToken);
writer.WriteOptionalSphere(asset.BoundingSphere);
writer.WriteBoolean(asset.VisualBounds.HasValue);
if (asset.VisualBounds is FlatGfxObjVisualBounds visual)
{
writer.WriteVector3(visual.Min);
writer.WriteVector3(visual.Max);
writer.WriteVector3(visual.Center);
writer.WriteSingle(visual.Radius);
writer.WriteVector3(visual.HalfExtents);
}
cancellationToken.ThrowIfCancellationRequested();
return writer.ToArray();
}
public static byte[] Serialize(
FlatSetupCollision asset,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(asset);
var writer = new CollisionWriter();
writer.WriteHeader(CollisionPayloadKind.Setup);
writer.WriteCount(asset.Cylinders.Length, "Setup cylinder");
writer.WriteCount(asset.Spheres.Length, "Setup sphere");
writer.WriteSingle(asset.Height);
writer.WriteSingle(asset.Radius);
writer.WriteSingle(asset.StepUpHeight);
writer.WriteSingle(asset.StepDownHeight);
for (int i = 0; i < asset.Cylinders.Length; i++)
{
CheckCancellation(cancellationToken, i);
FlatCollisionCylinder cylinder = asset.Cylinders[i];
writer.WriteVector3(cylinder.Origin);
writer.WriteSingle(cylinder.Radius);
writer.WriteSingle(cylinder.Height);
}
for (int i = 0; i < asset.Spheres.Length; i++)
{
CheckCancellation(cancellationToken, i);
writer.WriteSphere(asset.Spheres[i]);
}
cancellationToken.ThrowIfCancellationRequested();
return writer.ToArray();
}
public static byte[] Serialize(
FlatCellStructureCollisionAsset asset,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(asset);
var writer = new CollisionWriter();
writer.WriteHeader(CollisionPayloadKind.CellStructure);
WritePhysicsBsp(writer, asset.PhysicsBsp, cancellationToken);
WriteContainmentBsp(writer, asset.ContainmentBsp, cancellationToken);
WritePolygonTable(writer, asset.PortalPolygons, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return writer.ToArray();
}
public static byte[] Serialize(
FlatEnvCellTopology asset,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(asset);
var writer = new CollisionWriter();
writer.WriteHeader(CollisionPayloadKind.EnvCellTopology);
writer.WriteCount(asset.Portals.Length, "EnvCell portal");
writer.WriteCount(asset.VisibleCellIds.Length, "visible-cell");
writer.WriteBoolean(asset.SeenOutside);
for (int i = 0; i < asset.Portals.Length; i++)
{
CheckCancellation(cancellationToken, i);
FlatEnvCellPortal portal = asset.Portals[i];
writer.WriteUInt16(portal.OtherCellId);
writer.WriteUInt16(portal.PolygonId);
writer.WriteUInt16(portal.Flags);
writer.WriteInt32(portal.PolygonIndex);
}
for (int i = 0; i < asset.VisibleCellIds.Length; i++)
{
CheckCancellation(cancellationToken, i);
writer.WriteUInt32(asset.VisibleCellIds[i]);
}
cancellationToken.ThrowIfCancellationRequested();
return writer.ToArray();
}
public static FlatGfxObjCollisionAsset DeserializeGfxObj(
ReadOnlySpan<byte> bytes,
CancellationToken cancellationToken = default)
{
var reader = new CollisionReader(bytes, cancellationToken);
reader.ReadHeader(CollisionPayloadKind.GfxObj);
FlatPhysicsBsp physicsBsp = ReadPhysicsBsp(ref reader);
FlatCollisionSphere? boundingSphere = reader.ReadOptionalSphere();
FlatGfxObjVisualBounds? visualBounds = null;
if (reader.ReadBoolean())
{
visualBounds = new FlatGfxObjVisualBounds(
reader.ReadVector3(),
reader.ReadVector3(),
reader.ReadVector3(),
reader.ReadSingle(),
reader.ReadVector3());
}
reader.RequireEnd();
return new FlatGfxObjCollisionAsset(
physicsBsp,
boundingSphere,
visualBounds);
}
public static FlatSetupCollision DeserializeSetup(
ReadOnlySpan<byte> bytes,
CancellationToken cancellationToken = default)
{
var reader = new CollisionReader(bytes, cancellationToken);
reader.ReadHeader(CollisionPayloadKind.Setup);
int cylinderCount = reader.ReadCount(
"Setup cylinder",
bytesPerItem: 20);
int sphereCount = reader.ReadCount(
"Setup sphere",
bytesPerItem: 16);
reader.RequireRemaining(
checked(16L + (20L * cylinderCount) + (16L * sphereCount)),
"Setup collision rows");
float height = reader.ReadSingle();
float radius = reader.ReadSingle();
float stepUpHeight = reader.ReadSingle();
float stepDownHeight = reader.ReadSingle();
var cylinders =
ImmutableArray.CreateBuilder<FlatCollisionCylinder>(cylinderCount);
for (int i = 0; i < cylinderCount; i++)
{
reader.CheckCancellation(i);
cylinders.Add(new FlatCollisionCylinder(
reader.ReadVector3(),
reader.ReadSingle(),
reader.ReadSingle()));
}
var spheres =
ImmutableArray.CreateBuilder<FlatCollisionSphere>(sphereCount);
for (int i = 0; i < sphereCount; i++)
{
reader.CheckCancellation(i);
spheres.Add(reader.ReadSphere());
}
reader.RequireEnd();
return new FlatSetupCollision(
cylinders.MoveToImmutable(),
spheres.MoveToImmutable(),
height,
radius,
stepUpHeight,
stepDownHeight);
}
public static FlatCellStructureCollisionAsset DeserializeCellStructure(
ReadOnlySpan<byte> bytes,
CancellationToken cancellationToken = default)
{
var reader = new CollisionReader(bytes, cancellationToken);
reader.ReadHeader(CollisionPayloadKind.CellStructure);
FlatPhysicsBsp physicsBsp = ReadPhysicsBsp(ref reader);
FlatCellContainmentBsp containmentBsp = ReadContainmentBsp(ref reader);
FlatPolygonTable portalPolygons = ReadPolygonTable(ref reader);
reader.RequireEnd();
return new FlatCellStructureCollisionAsset(
physicsBsp,
containmentBsp,
portalPolygons);
}
public static FlatEnvCellTopology DeserializeEnvCellTopology(
ReadOnlySpan<byte> bytes,
CancellationToken cancellationToken = default)
{
var reader = new CollisionReader(bytes, cancellationToken);
reader.ReadHeader(CollisionPayloadKind.EnvCellTopology);
int portalCount = reader.ReadCount(
"EnvCell portal",
bytesPerItem: 10);
int visibleCount = reader.ReadCount(
"visible-cell",
bytesPerItem: 4);
reader.RequireRemaining(
checked(1L + (10L * portalCount) + (4L * visibleCount)),
"EnvCell topology rows");
bool seenOutside = reader.ReadBoolean();
var portals =
ImmutableArray.CreateBuilder<FlatEnvCellPortal>(portalCount);
for (int i = 0; i < portalCount; i++)
{
reader.CheckCancellation(i);
portals.Add(new FlatEnvCellPortal(
reader.ReadUInt16(),
reader.ReadUInt16(),
reader.ReadUInt16(),
reader.ReadInt32()));
}
var visible =
ImmutableArray.CreateBuilder<uint>(visibleCount);
for (int i = 0; i < visibleCount; i++)
{
reader.CheckCancellation(i);
visible.Add(reader.ReadUInt32());
}
reader.RequireEnd();
return new FlatEnvCellTopology(
portals.MoveToImmutable(),
visible.MoveToImmutable(),
seenOutside);
}
private static void WritePhysicsBsp(
CollisionWriter writer,
FlatPhysicsBsp bsp,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bsp);
writer.WriteInt32(bsp.RootIndex);
writer.WriteCount(bsp.Nodes.Length, "physics BSP node");
writer.WriteCount(
bsp.PolygonIndexStream.Length,
"physics BSP polygon-index");
for (int i = 0; i < bsp.Nodes.Length; i++)
{
CheckCancellation(cancellationToken, i);
FlatPhysicsBspNode node = bsp.Nodes[i];
writer.WriteUInt32((uint)node.Type);
writer.WritePlane(node.SplittingPlane);
writer.WriteInt32(node.PositiveChildIndex);
writer.WriteInt32(node.NegativeChildIndex);
writer.WriteInt32(node.LeafIndex);
writer.WriteInt32(node.Solid);
writer.WriteSphere(node.BoundingSphere);
writer.WriteRange(node.PolygonIndexRange);
}
for (int i = 0; i < bsp.PolygonIndexStream.Length; i++)
{
CheckCancellation(cancellationToken, i);
writer.WriteInt32(bsp.PolygonIndexStream[i]);
}
WritePolygonTable(writer, bsp.PolygonTable, cancellationToken);
}
private static FlatPhysicsBsp ReadPhysicsBsp(
ref CollisionReader reader)
{
int rootIndex = reader.ReadInt32();
int nodeCount = reader.ReadCount(
"physics BSP node",
bytesPerItem: 60);
int polygonIndexCount = reader.ReadCount(
"physics BSP polygon-index",
bytesPerItem: 4);
reader.RequireRemaining(
checked((60L * nodeCount) + (4L * polygonIndexCount) + 8L),
"physics BSP rows");
var nodes =
ImmutableArray.CreateBuilder<FlatPhysicsBspNode>(nodeCount);
for (int i = 0; i < nodeCount; i++)
{
reader.CheckCancellation(i);
nodes.Add(new FlatPhysicsBspNode(
(BSPNodeType)reader.ReadUInt32(),
reader.ReadPlane(),
reader.ReadInt32(),
reader.ReadInt32(),
reader.ReadInt32(),
reader.ReadInt32(),
reader.ReadSphere(),
reader.ReadRange()));
}
var polygonIndices =
ImmutableArray.CreateBuilder<int>(polygonIndexCount);
for (int i = 0; i < polygonIndexCount; i++)
{
reader.CheckCancellation(i);
polygonIndices.Add(reader.ReadInt32());
}
FlatPolygonTable polygons = ReadPolygonTable(ref reader);
return new FlatPhysicsBsp(
rootIndex,
nodes.MoveToImmutable(),
polygonIndices.MoveToImmutable(),
polygons);
}
private static void WriteContainmentBsp(
CollisionWriter writer,
FlatCellContainmentBsp bsp,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(bsp);
writer.WriteInt32(bsp.RootIndex);
writer.WriteCount(bsp.Nodes.Length, "cell-containment BSP node");
for (int i = 0; i < bsp.Nodes.Length; i++)
{
CheckCancellation(cancellationToken, i);
FlatCellBspNode node = bsp.Nodes[i];
writer.WriteUInt32((uint)node.Type);
writer.WritePlane(node.SplittingPlane);
writer.WriteInt32(node.PositiveChildIndex);
writer.WriteInt32(node.NegativeChildIndex);
writer.WriteInt32(node.LeafIndex);
}
}
private static FlatCellContainmentBsp ReadContainmentBsp(
ref CollisionReader reader)
{
int rootIndex = reader.ReadInt32();
int nodeCount = reader.ReadCount(
"cell-containment BSP node",
bytesPerItem: 32);
var nodes =
ImmutableArray.CreateBuilder<FlatCellBspNode>(nodeCount);
for (int i = 0; i < nodeCount; i++)
{
reader.CheckCancellation(i);
nodes.Add(new FlatCellBspNode(
(BSPNodeType)reader.ReadUInt32(),
reader.ReadPlane(),
reader.ReadInt32(),
reader.ReadInt32(),
reader.ReadInt32()));
}
return new FlatCellContainmentBsp(
rootIndex,
nodes.MoveToImmutable());
}
private static void WritePolygonTable(
CollisionWriter writer,
FlatPolygonTable table,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(table);
writer.WriteCount(table.Polygons.Length, "polygon");
writer.WriteCount(table.Vertices.Length, "polygon vertex");
for (int i = 0; i < table.Polygons.Length; i++)
{
CheckCancellation(cancellationToken, i);
FlatCollisionPolygon polygon = table.Polygons[i];
writer.WriteUInt16(polygon.Id);
writer.WriteUInt32((uint)polygon.SidesType);
writer.WriteInt32(polygon.NumPoints);
writer.WriteRange(polygon.VertexRange);
writer.WritePlane(polygon.Plane);
}
for (int i = 0; i < table.Vertices.Length; i++)
{
CheckCancellation(cancellationToken, i);
writer.WriteVector3(table.Vertices[i]);
}
}
private static FlatPolygonTable ReadPolygonTable(
ref CollisionReader reader)
{
int polygonCount = reader.ReadCount(
"polygon",
bytesPerItem: 34);
int vertexCount = reader.ReadCount(
"polygon vertex",
bytesPerItem: 12);
reader.RequireRemaining(
checked((34L * polygonCount) + (12L * vertexCount)),
"polygon-table rows");
var polygons =
ImmutableArray.CreateBuilder<FlatCollisionPolygon>(polygonCount);
for (int i = 0; i < polygonCount; i++)
{
reader.CheckCancellation(i);
polygons.Add(new FlatCollisionPolygon(
reader.ReadUInt16(),
reader.ReadPlaneAfterMetadata(out CullMode sides, out int count,
out FlatIndexRange range),
sides,
count,
range));
}
var vertices = ImmutableArray.CreateBuilder<Vector3>(vertexCount);
for (int i = 0; i < vertexCount; i++)
{
reader.CheckCancellation(i);
vertices.Add(reader.ReadVector3());
}
return new FlatPolygonTable(
polygons.MoveToImmutable(),
vertices.MoveToImmutable());
}
private static void CheckCancellation(
CancellationToken cancellationToken,
int index)
{
if ((index & 0xFF) == 0)
cancellationToken.ThrowIfCancellationRequested();
}
private enum CollisionPayloadKind : byte
{
GfxObj = 1,
Setup = 2,
CellStructure = 3,
EnvCellTopology = 4,
}
private sealed class CollisionWriter
{
private readonly ArrayBufferWriter<byte> _buffer = new(256);
public void WriteHeader(CollisionPayloadKind kind)
{
WriteUInt32(Magic);
WriteByte((byte)kind);
WriteByte(SchemaVersion);
WriteUInt16(0);
}
public void WriteCount(int value, string name)
{
if ((uint)value > MaximumRows)
{
throw new InvalidDataException(
$"{name} count {value} exceeds {MaximumRows}.");
}
WriteInt32(value);
}
public void WriteOptionalSphere(FlatCollisionSphere? sphere)
{
WriteBoolean(sphere.HasValue);
if (sphere.HasValue)
WriteSphere(sphere.Value);
}
public void WriteBoolean(bool value) => WriteByte(value ? (byte)1 : (byte)0);
public void WriteSphere(FlatCollisionSphere sphere)
{
WriteVector3(sphere.Origin);
WriteSingle(sphere.Radius);
}
public void WriteRange(FlatIndexRange range)
{
WriteInt32(range.Start);
WriteInt32(range.Count);
}
public void WritePlane(Plane plane)
{
WriteVector3(plane.Normal);
WriteSingle(plane.D);
}
public void WriteVector3(Vector3 vector)
{
WriteSingle(vector.X);
WriteSingle(vector.Y);
WriteSingle(vector.Z);
}
public void WriteSingle(float value) =>
WriteInt32(BitConverter.SingleToInt32Bits(value));
public void WriteByte(byte value)
{
Span<byte> span = _buffer.GetSpan(1);
span[0] = value;
_buffer.Advance(1);
}
public void WriteUInt16(ushort value)
{
Span<byte> span = _buffer.GetSpan(sizeof(ushort));
BinaryPrimitives.WriteUInt16LittleEndian(span, value);
_buffer.Advance(sizeof(ushort));
}
public void WriteInt32(int value)
{
Span<byte> span = _buffer.GetSpan(sizeof(int));
BinaryPrimitives.WriteInt32LittleEndian(span, value);
_buffer.Advance(sizeof(int));
}
public void WriteUInt32(uint value)
{
Span<byte> span = _buffer.GetSpan(sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(span, value);
_buffer.Advance(sizeof(uint));
}
public byte[] ToArray() => _buffer.WrittenSpan.ToArray();
}
private ref struct CollisionReader
{
private readonly ReadOnlySpan<byte> _bytes;
private readonly CancellationToken _cancellationToken;
private int _offset;
public CollisionReader(
ReadOnlySpan<byte> bytes,
CancellationToken cancellationToken)
{
_bytes = bytes;
_cancellationToken = cancellationToken;
_offset = 0;
_cancellationToken.ThrowIfCancellationRequested();
}
private int Remaining => _bytes.Length - _offset;
public void ReadHeader(CollisionPayloadKind expectedKind)
{
uint magic = ReadUInt32();
byte kind = ReadByte();
byte version = ReadByte();
ushort reserved = ReadUInt16();
if (magic != Magic)
{
throw new InvalidDataException(
$"collision payload magic 0x{magic:X8} does not match " +
$"0x{Magic:X8}.");
}
if (kind != (byte)expectedKind)
{
throw new InvalidDataException(
$"collision payload kind {kind} does not match " +
$"{(byte)expectedKind}.");
}
if (version != SchemaVersion)
{
throw new InvalidDataException(
$"collision payload schema {version} is unsupported.");
}
if (reserved != 0)
{
throw new InvalidDataException(
"collision payload reserved header bits are non-zero.");
}
}
public int ReadCount(string name, int bytesPerItem)
{
int count = ReadInt32();
if (count < 0 || count > MaximumRows)
{
throw new InvalidDataException(
$"{name} count {count} is outside [0,{MaximumRows}].");
}
if (bytesPerItem <= 0 || count > Remaining / bytesPerItem)
{
throw new InvalidDataException(
$"{name} count {count} exceeds the remaining payload.");
}
return count;
}
public FlatCollisionSphere? ReadOptionalSphere() =>
ReadBoolean() ? ReadSphere() : null;
public bool ReadBoolean()
{
byte value = ReadByte();
return value switch
{
0 => false,
1 => true,
_ => throw new InvalidDataException(
$"invalid collision boolean value {value}."),
};
}
public FlatCollisionSphere ReadSphere() =>
new(ReadVector3(), ReadSingle());
public FlatIndexRange ReadRange() =>
new(ReadInt32(), ReadInt32());
public Plane ReadPlane() =>
new(ReadVector3(), ReadSingle());
public Plane ReadPlaneAfterMetadata(
out CullMode sides,
out int numPoints,
out FlatIndexRange range)
{
sides = (CullMode)ReadUInt32();
numPoints = ReadInt32();
range = ReadRange();
return ReadPlane();
}
public Vector3 ReadVector3() =>
new(ReadSingle(), ReadSingle(), ReadSingle());
public float ReadSingle() =>
BitConverter.Int32BitsToSingle(ReadInt32());
public byte ReadByte()
{
Require(sizeof(byte));
return _bytes[_offset++];
}
public ushort ReadUInt16()
{
ReadOnlySpan<byte> span = Take(sizeof(ushort));
return BinaryPrimitives.ReadUInt16LittleEndian(span);
}
public int ReadInt32()
{
ReadOnlySpan<byte> span = Take(sizeof(int));
return BinaryPrimitives.ReadInt32LittleEndian(span);
}
public uint ReadUInt32()
{
ReadOnlySpan<byte> span = Take(sizeof(uint));
return BinaryPrimitives.ReadUInt32LittleEndian(span);
}
public void CheckCancellation(int index)
{
if ((index & 0xFF) == 0)
_cancellationToken.ThrowIfCancellationRequested();
}
public void RequireEnd()
{
_cancellationToken.ThrowIfCancellationRequested();
if (_offset != _bytes.Length)
{
throw new InvalidDataException(
$"collision payload contains {_bytes.Length - _offset} " +
"trailing byte(s).");
}
}
public void RequireRemaining(long required, string name)
{
if (required < 0 || required > Remaining)
{
throw new InvalidDataException(
$"{name} require {required} bytes but only {Remaining} remain.");
}
}
private ReadOnlySpan<byte> Take(int length)
{
Require(length);
ReadOnlySpan<byte> result = _bytes.Slice(_offset, length);
_offset += length;
return result;
}
private void Require(int length)
{
if (length < 0 || length > Remaining)
throw new EndOfStreamException("collision payload is truncated.");
}
}
}

View file

@ -131,7 +131,10 @@ internal static class PreparedAssetRequestContract
{ {
public static void ValidateType(PakAssetType type) public static void ValidateType(PakAssetType type)
{ {
if (!Enum.IsDefined(type)) if (type is not (
PakAssetType.GfxObjMesh or
PakAssetType.SetupMesh or
PakAssetType.EnvCellMesh))
{ {
throw new ArgumentOutOfRangeException( throw new ArgumentOutOfRangeException(
nameof(type), nameof(type),
@ -166,7 +169,9 @@ internal static class PreparedAssetRequestContract
/// Production prepared source backed by one immutable memory-mapped pak. /// Production prepared source backed by one immutable memory-mapped pak.
/// There is intentionally no DAT fallback. /// There is intentionally no DAT fallback.
/// </summary> /// </summary>
public sealed class PakPreparedAssetSource : IPreparedAssetSource public sealed partial class PakPreparedAssetSource :
IPreparedAssetSource,
IPreparedCollisionSource
{ {
private readonly PakReader _reader; private readonly PakReader _reader;
private readonly Action<string>? _diagnosticSink; private readonly Action<string>? _diagnosticSink;

View file

@ -0,0 +1,206 @@
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content;
public readonly record struct PreparedCollisionSourceStats(
long Probes,
long Reads,
long Loaded,
long Missing,
long Corrupt);
public readonly record struct PreparedCollisionReadResult<T>(
PreparedAssetReadStatus Status,
T? Data)
where T : class
{
public static PreparedCollisionReadResult<T> Missing =>
new(PreparedAssetReadStatus.Missing, null);
public static PreparedCollisionReadResult<T> Corrupt =>
new(PreparedAssetReadStatus.Corrupt, null);
public static PreparedCollisionReadResult<T> Loaded(T data) =>
new(PreparedAssetReadStatus.Loaded, data);
}
/// <summary>
/// Typed immutable collision payload source. It is deliberately separate from
/// the render-only <see cref="IPreparedAssetSource"/> contract even when one
/// concrete package owner implements both interfaces over the same mmap.
/// </summary>
public interface IPreparedCollisionSource : IDisposable
{
PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId);
PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default);
PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default);
PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default);
PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default);
PreparedCollisionSourceStats CollisionStats { get; }
}
internal static class PreparedCollisionTypeContract
{
public static void Validate(PakAssetType type)
{
if (type is not (
PakAssetType.GfxObjCollision or
PakAssetType.SetupCollision or
PakAssetType.CellStructureCollision or
PakAssetType.EnvCellTopology))
{
throw new ArgumentOutOfRangeException(
nameof(type),
type,
"unknown prepared collision asset type");
}
}
}
public sealed partial class PakPreparedAssetSource
{
private long _collisionProbes;
private long _collisionReads;
private long _collisionLoaded;
private long _collisionMissing;
private long _collisionCorrupt;
public PreparedCollisionSourceStats CollisionStats =>
new(
Volatile.Read(ref _collisionProbes),
Volatile.Read(ref _collisionReads),
Volatile.Read(ref _collisionLoaded),
Volatile.Read(ref _collisionMissing),
Volatile.Read(ref _collisionCorrupt));
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId)
{
PreparedCollisionTypeContract.Validate(type);
Interlocked.Increment(ref _collisionProbes);
ulong key = PakKey.Compose(type, sourceFileId);
return _reader.ProbeEntry(key) switch
{
PakEntryState.Available => PreparedAssetPresence.Available,
PakEntryState.Corrupt => PreparedAssetPresence.Corrupt,
_ => PreparedAssetPresence.Missing,
};
}
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
ReadCollision(
PakAssetType.GfxObjCollision,
sourceFileId,
static (bytes, token) =>
FlatCollisionAssetSerializer.DeserializeGfxObj(bytes, token),
cancellationToken);
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
ReadCollision(
PakAssetType.SetupCollision,
sourceFileId,
static (bytes, token) =>
FlatCollisionAssetSerializer.DeserializeSetup(bytes, token),
cancellationToken);
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
ReadCollision(
PakAssetType.CellStructureCollision,
sourceFileId,
static (bytes, token) =>
FlatCollisionAssetSerializer.DeserializeCellStructure(
bytes,
token),
cancellationToken);
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
ReadCollision(
PakAssetType.EnvCellTopology,
sourceFileId,
static (bytes, token) =>
FlatCollisionAssetSerializer.DeserializeEnvCellTopology(
bytes,
token),
cancellationToken);
private PreparedCollisionReadResult<T> ReadCollision<T>(
PakAssetType type,
uint sourceFileId,
Func<byte[], CancellationToken, T> deserialize,
CancellationToken cancellationToken)
where T : class
{
PreparedCollisionTypeContract.Validate(type);
cancellationToken.ThrowIfCancellationRequested();
Interlocked.Increment(ref _collisionReads);
ulong key = PakKey.Compose(type, sourceFileId);
PakObjectReadStatus status =
_reader.ReadBlobBytes(key, out byte[]? bytes);
cancellationToken.ThrowIfCancellationRequested();
if (status == PakObjectReadStatus.Missing)
{
Interlocked.Increment(ref _collisionMissing);
return PreparedCollisionReadResult<T>.Missing;
}
if (status == PakObjectReadStatus.Corrupt || bytes is null)
{
Interlocked.Increment(ref _collisionCorrupt);
return PreparedCollisionReadResult<T>.Corrupt;
}
try
{
T data = deserialize(bytes, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
Interlocked.Increment(ref _collisionLoaded);
return PreparedCollisionReadResult<T>.Loaded(data);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_reader.MarkPayloadCorrupt(
key,
$"collision deserialization failed despite matching CRC: " +
$"{exception.GetType().Name}: {exception.Message}");
Interlocked.Increment(ref _collisionCorrupt);
return PreparedCollisionReadResult<T>.Corrupt;
}
}
}

View file

@ -20,10 +20,11 @@ public static class PakFormat {
/// Identity of the bake algorithm that produced the payloads. Version 2 /// Identity of the bake algorithm that produced the payloads. Version 2
/// introduced content-identity EnvCell blobs with ordinary TOC aliases; /// introduced content-identity EnvCell blobs with ordinary TOC aliases;
/// version 3 embeds exact render-pass translucency in each texture batch /// version 3 embeds exact render-pass translucency in each texture batch
/// so production never rebuilds surface metadata from live DAT. /// so production never rebuilds surface metadata from live DAT. Version 4
/// adds complete immutable flat collision and EnvCell-topology payloads.
/// The binary format remains version 1. /// The binary format remains version 1.
/// </summary> /// </summary>
public const uint CurrentBakeToolVersion = 3; public const uint CurrentBakeToolVersion = 4;
} }
/// <summary> /// <summary>

View file

@ -11,6 +11,10 @@ public enum PakAssetType : byte {
GfxObjMesh = 1, GfxObjMesh = 1,
SetupMesh = 2, SetupMesh = 2,
EnvCellMesh = 3, EnvCellMesh = 3,
GfxObjCollision = 4,
SetupCollision = 5,
CellStructureCollision = 6,
EnvCellTopology = 7,
} }
/// <summary> /// <summary>

View file

@ -145,6 +145,44 @@ public sealed class PakReader : IDisposable {
ulong key, ulong key,
out ObjectMeshData? data) { out ObjectMeshData? data) {
data = null; data = null;
PakAssetType type = PakKey.Decompose(key).Type;
if (type is not (
PakAssetType.GfxObjMesh or
PakAssetType.SetupMesh or
PakAssetType.EnvCellMesh)) {
return PakObjectReadStatus.Missing;
}
PakObjectReadStatus status = ReadBlobBytes(key, out byte[]? bytes);
if (status != PakObjectReadStatus.Loaded || bytes is null)
return status;
try {
data = ObjectMeshDataSerializer.Read(bytes);
return PakObjectReadStatus.Loaded;
}
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or
// a tamper that recomputed the CRC). External-file input: demote
// to missing, log once — never propagate from a lookup.
data = null;
MarkPayloadCorrupt(
key,
$"deserialization failed despite matching CRC: " +
$"{ex.GetType().Name}: {ex.Message}");
return PakObjectReadStatus.Corrupt;
}
}
/// <summary>
/// Copies and CRC-verifies one typed blob without interpreting its schema.
/// Content-layer typed sources deserialize the returned bytes and call
/// <see cref="MarkPayloadCorrupt"/> on structural failure.
/// </summary>
internal PakObjectReadStatus ReadBlobBytes(
ulong key,
out byte[]? bytes) {
bytes = null;
int index = BinarySearch(key); int index = BinarySearch(key);
if (index < 0) return PakObjectReadStatus.Missing; if (index < 0) return PakObjectReadStatus.Missing;
@ -156,7 +194,7 @@ public sealed class PakReader : IDisposable {
// Single pass (review finding 4): ONE copy out of the map; CRC and // Single pass (review finding 4): ONE copy out of the map; CRC and
// deserialization both run over this same buffer. // deserialization both run over this same buffer.
ref readonly var entry = ref _toc[index]; ref readonly var entry = ref _toc[index];
var bytes = new byte[entry.Length]; bytes = new byte[entry.Length];
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length); _accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
if (!judged) { if (!judged) {
@ -164,24 +202,21 @@ public sealed class PakReader : IDisposable {
if (actualCrc != entry.Crc32) { if (actualCrc != entry.Crc32) {
_entryVerdictByTocIndex[index] = 0; _entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})"); LogCorruptionOnce(index, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})");
bytes = null;
return PakObjectReadStatus.Corrupt; return PakObjectReadStatus.Corrupt;
} }
_entryVerdictByTocIndex[index] = 1; _entryVerdictByTocIndex[index] = 1;
} }
try {
data = ObjectMeshDataSerializer.Read(bytes);
return PakObjectReadStatus.Loaded; return PakObjectReadStatus.Loaded;
} }
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or internal void MarkPayloadCorrupt(ulong key, string reason) {
// a tamper that recomputed the CRC). External-file input: demote int index = BinarySearch(key);
// to missing, log once — never propagate from a lookup. if (index < 0)
data = null; return;
_entryVerdictByTocIndex[index] = 0; _entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"deserialization failed despite matching CRC: {ex.GetType().Name}: {ex.Message}"); LogCorruptionOnce(index, reason);
return PakObjectReadStatus.Corrupt;
}
} }
/// <summary>Returns the blob's file offset for alignment assertions in tests.</summary> /// <summary>Returns the blob's file offset for alignment assertions in tests.</summary>
@ -198,6 +233,20 @@ public sealed class PakReader : IDisposable {
return _toc[index]; return _toc[index];
} }
/// <summary>
/// Counts one typed catalog partition without touching payload bytes.
/// Used by the offline publisher to prove package completeness.
/// </summary>
public int CountEntries(PakAssetType type) {
byte rawType = (byte)type;
int count = 0;
for (int i = 0; i < _toc.Length; i++) {
if ((byte)(_toc[i].Key >> 56) == rawType)
count++;
}
return count;
}
/// <summary> /// <summary>
/// Validates the complete immutable TOC before an offline bake publishes /// Validates the complete immutable TOC before an offline bake publishes
/// it. This is intentionally stricter than runtime lookup: key order and /// it. This is intentionally stricter than runtime lookup: key order and

View file

@ -46,28 +46,51 @@ public sealed class PakWriter : IDisposable {
/// </summary> /// </summary>
public void AddBlob(ulong key, ObjectMeshData data) { public void AddBlob(ulong key, ObjectMeshData data) {
ThrowIfFinished(); ThrowIfFinished();
if (!_seenKeys.Add(key)) { ReserveKey(key);
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
long offset = _stream.Position;
using var ms = new MemoryStream(); using var ms = new MemoryStream();
ObjectMeshDataSerializer.Write(data, ms); ObjectMeshDataSerializer.Write(data, ms);
var bytes = ms.ToArray(); var bytes = ms.ToArray();
AddReservedBlob(key, bytes);
}
/// <summary>
/// Adds an already serialized immutable payload. This is the package seam
/// used by typed non-render assets such as flat collision records.
/// </summary>
public void AddBlob(ulong key, ReadOnlySpan<byte> bytes) {
ThrowIfFinished();
ReserveKey(key);
AddReservedBlob(key, bytes);
}
private void AddReservedBlob(ulong key, ReadOnlySpan<byte> bytes) {
if ((ulong)bytes.Length > uint.MaxValue) {
throw new ArgumentException(
"one pak payload cannot exceed UInt32.MaxValue bytes",
nameof(bytes));
}
long offset = _stream.Position;
_stream.Write(bytes); _stream.Write(bytes);
PadToAlignment(); PadToAlignment();
var receipt = new PakTocEntry { var receipt = new PakTocEntry {
Key = key, Key = key,
Offset = (ulong)offset, Offset = (ulong)offset,
Length = (uint)bytes.Length, Length = checked((uint)bytes.Length),
Crc32 = Content.Pak.Crc32.Compute(bytes), Crc32 = Content.Pak.Crc32.Compute(bytes),
}; };
_tocEntries.Add(receipt); _tocEntries.Add(receipt);
_receiptByKey.Add(key, receipt); _receiptByKey.Add(key, receipt);
} }
private void ReserveKey(ulong key) {
if (!_seenKeys.Add(key)) {
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
}
/// <summary> /// <summary>
/// Adds a second independently addressable key for an existing physical /// Adds a second independently addressable key for an existing physical
/// blob. The alias receives a normal TOC row with the source blob's exact /// blob. The alias receives a normal TOC row with the source blob's exact

View file

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

View file

@ -104,7 +104,7 @@ public sealed class PhysicsDataCache
/// Used as a fallback collision shape for entities whose Setup has no /// Used as a fallback collision shape for entities whose Setup has no
/// physics data — we approximate collision using the visual extent. /// physics data — we approximate collision using the visual extent.
/// </summary> /// </summary>
private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray) internal static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
{ {
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0) if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
{ {

View file

@ -76,6 +76,43 @@ public sealed class BakeDeterminismTests : IDisposable {
$"pak sizes differ between runs: {bytesA.Length} vs {bytesB.Length} bytes"); $"pak sizes differ between runs: {bytesA.Length} vs {bytesB.Length} bytes");
Assert.True(bytesA.AsSpan().SequenceEqual(bytesB), Assert.True(bytesA.AsSpan().SequenceEqual(bytesB),
"two bakes of the same id set produced different bytes — the sorted-batching determinism guarantee is broken"); "two bakes of the same id set produced different bytes — the sorted-batching determinism guarantee is broken");
using var reader = new PakReader(pathA);
foreach (uint gfxId in new[]
{
0x01000001u,
0x010002B4u,
0x010008A8u,
0x010014C3u,
})
{
Assert.True(reader.ContainsKey(PakKey.Compose(
PakAssetType.GfxObjCollision,
gfxId)));
}
foreach (uint setupId in new[]
{
0x02000001u,
0x020019FFu,
0x020005D8u,
})
{
Assert.True(reader.ContainsKey(PakKey.Compose(
PakAssetType.SetupCollision,
setupId)));
}
foreach (uint cellId in new[]
{
0xA9B40100u,
0xA9B40101u,
})
{
Assert.True(reader.ContainsKey(PakKey.Compose(
PakAssetType.CellStructureCollision,
cellId)));
Assert.True(reader.ContainsKey(PakKey.Compose(
PakAssetType.EnvCellTopology,
cellId)));
}
} }
[Fact] [Fact]
@ -111,7 +148,11 @@ public sealed class BakeDeterminismTests : IDisposable {
Assert.Equal(8, reportA.EnvCellKeys); Assert.Equal(8, reportA.EnvCellKeys);
Assert.Equal(1, reportA.UniqueEnvCellGeometries); Assert.Equal(1, reportA.UniqueEnvCellGeometries);
Assert.Equal(7, reportA.EnvCellAliases); Assert.Equal(7, reportA.EnvCellAliases);
Assert.Equal(1, reportA.PhysicalBlobs); Assert.Equal(8, reportA.CellStructureCollisionKeys);
Assert.Equal(1, reportA.UniqueCellStructureCollisions);
Assert.Equal(7, reportA.CellStructureCollisionAliases);
Assert.Equal(8, reportA.EnvCellTopologyKeys);
Assert.Equal(10, reportA.PhysicalBlobs);
Assert.Equal(reportA.TotalKeys, reportB.TotalKeys); Assert.Equal(reportA.TotalKeys, reportB.TotalKeys);
Assert.True(File.ReadAllBytes(pathA).AsSpan().SequenceEqual(File.ReadAllBytes(pathB))); Assert.True(File.ReadAllBytes(pathA).AsSpan().SequenceEqual(File.ReadAllBytes(pathB)));
@ -124,6 +165,27 @@ public sealed class BakeDeterminismTests : IDisposable {
Assert.All(receipts, receipt => Assert.Equal(receipts[0].Length, receipt.Length)); Assert.All(receipts, receipt => Assert.Equal(receipts[0].Length, receipt.Length));
Assert.All(receipts, receipt => Assert.Equal(receipts[0].Crc32, receipt.Crc32)); Assert.All(receipts, receipt => Assert.Equal(receipts[0].Crc32, receipt.Crc32));
var collisionReceipts = ids
.Select(id => reader.GetTocEntryForTest(
PakKey.Compose(PakAssetType.CellStructureCollision, id)))
.ToArray();
Assert.All(
collisionReceipts,
receipt => Assert.Equal(
collisionReceipts[0].Offset,
receipt.Offset));
Assert.All(
collisionReceipts,
receipt => Assert.Equal(
collisionReceipts[0].Length,
receipt.Length));
Assert.Equal(
ids.Count,
ids.Select(id => reader.GetTocEntryForTest(
PakKey.Compose(PakAssetType.EnvCellTopology, id)).Offset)
.Distinct()
.Count());
Assert.True( Assert.True(
reader.TryReadObjectMeshData( reader.TryReadObjectMeshData(
PakKey.Compose(PakAssetType.EnvCellMesh, ids.Min()), PakKey.Compose(PakAssetType.EnvCellMesh, ids.Min()),
@ -132,4 +194,34 @@ public sealed class BakeDeterminismTests : IDisposable {
Assert.Equal(0xE000_0000_0000_0000UL, Assert.Equal(0xE000_0000_0000_0000UL,
geometry.ObjectId & 0xF000_0000_0000_0000UL); geometry.ObjectId & 0xF000_0000_0000_0000UL);
} }
[Fact]
public void Bake_CancellationCannotReplaceLastGoodPackage()
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
string destination = NewTempPakPath();
byte[] lastGood = [0x41, 0x43, 0x50, 0x4B, 1, 2, 3, 4];
File.WriteAllBytes(destination, lastGood);
using var cancellation = new CancellationTokenSource();
cancellation.CancelAfter(TimeSpan.FromMilliseconds(20));
Assert.Throws<OperationCanceledException>(
() => BakeRunner.RunDetailed(
new BakeOptions
{
DatDir = datDir,
OutPath = destination,
Threads = 2,
CancellationToken = cancellation.Token,
}));
Assert.Equal(lastGood, File.ReadAllBytes(destination));
Assert.Empty(
Directory.EnumerateFiles(
Path.GetDirectoryName(destination)!,
$".{Path.GetFileName(destination)}.*.tmp"));
}
} }

View file

@ -154,6 +154,33 @@ public sealed class BakeOutputTransactionTests : IDisposable
() => BakeArtifactValidator.Validate(pak, expected, expectedTocCount: 1)); () => BakeArtifactValidator.Validate(pak, expected, expectedTocCount: 1));
} }
[Fact]
public void ArtifactValidator_RejectsIncompleteTypedCatalog()
{
string pak = Path.Combine(_directory, "typed-content.pak");
var expected = Header();
using (var writer = new PakWriter(pak, expected))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, 1),
new ObjectMeshData { ObjectId = 1 });
writer.Finish();
}
var expectedTypes = new Dictionary<PakAssetType, int>
{
[PakAssetType.GfxObjMesh] = 1,
[PakAssetType.GfxObjCollision] = 1,
};
InvalidDataException exception = Assert.Throws<InvalidDataException>(
() => BakeArtifactValidator.Validate(
pak,
expected,
expectedTocCount: 1,
expectedTypeCounts: expectedTypes));
Assert.Contains("GfxObjCollision", exception.Message);
}
private PakHeader Header() => private PakHeader Header() =>
new() new()
{ {

View file

@ -0,0 +1,24 @@
namespace AcDream.Bake.Tests;
public sealed class ExactPayloadAliasCatalogTests
{
[Fact]
public void AliasRequiresCompleteByteEquality()
{
var catalog = new ExactPayloadAliasCatalog();
byte[] primary = [1, 2, 3, 4, 5];
catalog.Add(0x1234UL, primary);
Assert.True(catalog.TryFind(
[1, 2, 3, 4, 5],
out ulong primaryKey));
Assert.Equal(0x1234UL, primaryKey);
Assert.False(catalog.TryFind(
[1, 2, 3, 4, 4],
out _));
Assert.False(catalog.TryFind(
[1, 2, 3, 4, 5, 0],
out _));
}
}

View file

@ -0,0 +1,512 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
namespace AcDream.Content.Tests;
public sealed class FlatCollisionAssetSerializerTests : IDisposable
{
private static readonly PreparedAssetCatalogIdentity Identity =
new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
private readonly List<string> _paths = [];
[Fact]
public void EveryPayload_RoundTripsToByteIdenticalRepresentation()
{
FlatGfxObjCollisionAsset gfx = GfxAsset();
FlatSetupCollision setup = SetupAsset();
FlatCellStructureCollisionAsset structure = CellStructureAsset();
FlatEnvCellTopology topology = TopologyAsset();
byte[] gfxBytes = FlatCollisionAssetSerializer.Serialize(gfx);
byte[] setupBytes = FlatCollisionAssetSerializer.Serialize(setup);
byte[] structureBytes =
FlatCollisionAssetSerializer.Serialize(structure);
byte[] topologyBytes =
FlatCollisionAssetSerializer.Serialize(topology);
FlatGfxObjCollisionAsset readGfx =
FlatCollisionAssetSerializer.DeserializeGfxObj(gfxBytes);
FlatSetupCollision readSetup =
FlatCollisionAssetSerializer.DeserializeSetup(setupBytes);
FlatCellStructureCollisionAsset readStructure =
FlatCollisionAssetSerializer.DeserializeCellStructure(
structureBytes);
FlatEnvCellTopology readTopology =
FlatCollisionAssetSerializer.DeserializeEnvCellTopology(
topologyBytes);
Assert.Equal(
gfxBytes,
FlatCollisionAssetSerializer.Serialize(readGfx));
Assert.Equal(
setupBytes,
FlatCollisionAssetSerializer.Serialize(readSetup));
Assert.Equal(
structureBytes,
FlatCollisionAssetSerializer.Serialize(readStructure));
Assert.Equal(
topologyBytes,
FlatCollisionAssetSerializer.Serialize(readTopology));
AssertFloatBits(
gfx.VisualBounds!.Value.Radius,
readGfx.VisualBounds!.Value.Radius);
AssertFloatBits(
setup.StepDownHeight,
readSetup.StepDownHeight);
AssertFloatBits(
structure.PhysicsBsp.Nodes[0].SplittingPlane.D,
readStructure.PhysicsBsp.Nodes[0].SplittingPlane.D);
Assert.Equal(
topology.VisibleCellIds.AsEnumerable(),
readTopology.VisibleCellIds.AsEnumerable());
}
[Fact]
public void Encoding_IsLittleEndianAndPreservesSpecialFloatBits()
{
float special = BitConverter.Int32BitsToSingle(
unchecked((int)0xFFC0_1234u));
var setup = new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
special,
-0f,
float.PositiveInfinity,
float.NegativeInfinity);
byte[] bytes = FlatCollisionAssetSerializer.Serialize(setup);
// Header is "ACCL", kind=Setup, schema=1, reserved=0. Counts follow,
// then the exact little-endian IEEE-754 bits for Height.
Assert.Equal(
new byte[]
{
0x41, 0x43, 0x43, 0x4C,
0x02, 0x01, 0x00, 0x00,
},
bytes[..8]);
Assert.Equal(
new byte[] { 0x34, 0x12, 0xC0, 0xFF },
bytes[16..20]);
FlatSetupCollision read =
FlatCollisionAssetSerializer.DeserializeSetup(bytes);
AssertFloatBits(special, read.Height);
AssertFloatBits(-0f, read.Radius);
AssertFloatBits(float.PositiveInfinity, read.StepUpHeight);
AssertFloatBits(float.NegativeInfinity, read.StepDownHeight);
}
[Fact]
public void Readers_RejectWrongKindTruncationTrailingAndInvalidCounts()
{
byte[] setup = FlatCollisionAssetSerializer.Serialize(SetupAsset());
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeGfxObj(setup));
for (int length = 0; length < setup.Length; length++)
{
Assert.ThrowsAny<Exception>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
setup.AsSpan(0, length)));
}
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
[.. setup, 0x00]));
byte[] negativeCount = (byte[])setup.Clone();
negativeCount[8] = 0xFF;
negativeCount[9] = 0xFF;
negativeCount[10] = 0xFF;
negativeCount[11] = 0xFF;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
negativeCount));
byte[] impossibleCombinedCounts = (byte[])setup.Clone();
impossibleCombinedCounts[8] = 0x03;
impossibleCombinedCounts[12] = 0x03;
Assert.Throws<InvalidDataException>(
() => FlatCollisionAssetSerializer.DeserializeSetup(
impossibleCombinedCounts));
}
[Fact]
public void SerializeAndDeserialize_PropagatePreCancellation()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(
() => FlatCollisionAssetSerializer.Serialize(
GfxAsset(),
cancellation.Token));
byte[] bytes =
FlatCollisionAssetSerializer.Serialize(GfxAsset());
Assert.Throws<OperationCanceledException>(
() => FlatCollisionAssetSerializer.DeserializeGfxObj(
bytes,
cancellation.Token));
}
[Fact]
public void PreparedSource_ReadsAllCollisionKindsThroughSamePackageOwner()
{
const uint gfxId = 0x0100_0001u;
const uint setupId = 0x0200_0001u;
const uint cellId = 0xA9B4_0101u;
string path = NewPath();
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjCollision, gfxId),
FlatCollisionAssetSerializer.Serialize(GfxAsset()));
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.AddBlob(
PakKey.Compose(
PakAssetType.CellStructureCollision,
cellId),
FlatCollisionAssetSerializer.Serialize(CellStructureAsset()));
writer.AddBlob(
PakKey.Compose(PakAssetType.EnvCellTopology, cellId),
FlatCollisionAssetSerializer.Serialize(TopologyAsset()));
writer.Finish();
}
using var owner = new PakPreparedAssetSource(path, Identity);
IPreparedAssetSource renderSource = owner;
IPreparedCollisionSource collisionSource = owner;
Assert.Equal(
PreparedAssetPresence.Available,
collisionSource.ProbeCollision(
PakAssetType.GfxObjCollision,
gfxId));
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadGfxObjCollision(gfxId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadSetupCollision(setupId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadCellStructureCollision(cellId).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
collisionSource.ReadEnvCellTopology(cellId).Status);
Assert.Equal(4, collisionSource.CollisionStats.Loaded);
Assert.Equal(0, renderSource.Stats.Reads);
Assert.Equal(new FileInfo(path).Length, renderSource.MappedVirtualBytes);
Assert.Throws<ArgumentOutOfRangeException>(
() => renderSource.Probe(
PakAssetType.GfxObjCollision,
gfxId));
Assert.Throws<ArgumentOutOfRangeException>(
() => collisionSource.ProbeCollision(
PakAssetType.GfxObjMesh,
gfxId));
}
[Fact]
public void PreparedSource_DistinguishesMissingCrcAndSchemaCorruption()
{
const uint setupId = 0x0200_0001u;
string missingPath = NewPath();
using (var writer = new PakWriter(missingPath, Header()))
writer.Finish();
using (var source = new PakPreparedAssetSource(
missingPath,
Identity))
{
Assert.Equal(
PreparedAssetReadStatus.Missing,
source.ReadSetupCollision(setupId).Status);
}
string schemaPath = NewPath();
byte[] malformed =
[.. FlatCollisionAssetSerializer.Serialize(SetupAsset()), 0x7F];
using (var writer = new PakWriter(schemaPath, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
malformed);
writer.Finish();
}
using (var source = new PakPreparedAssetSource(schemaPath, Identity))
{
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
source.ReadSetupCollision(setupId).Status);
Assert.Equal(
PreparedAssetPresence.Corrupt,
source.ProbeCollision(
PakAssetType.SetupCollision,
setupId));
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
source.ReadSetupCollision(setupId).Status);
}
string crcPath = NewPath();
using (var writer = new PakWriter(crcPath, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
FlipFirstBlobByte(crcPath);
using var corruptSource =
new PakPreparedAssetSource(crcPath, Identity);
Assert.Equal(
PreparedAssetReadStatus.Corrupt,
corruptSource.ReadSetupCollision(setupId).Status);
}
[Fact]
public void PreparedSource_ConcurrentReadsRemainIndependentAndComplete()
{
const uint setupId = 0x0200_0001u;
string path = NewPath();
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, setupId),
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
using var source = new PakPreparedAssetSource(path, Identity);
Parallel.For(
0,
64,
_ =>
{
PreparedCollisionReadResult<FlatSetupCollision> result =
source.ReadSetupCollision(setupId);
Assert.Equal(PreparedAssetReadStatus.Loaded, result.Status);
Assert.NotNull(result.Data);
});
Assert.Equal(64, source.CollisionStats.Reads);
Assert.Equal(64, source.CollisionStats.Loaded);
Assert.Equal(0, source.CollisionStats.Corrupt);
}
[Fact]
public void RenderDecoder_CannotPoisonCollisionTypedEntry()
{
const uint setupId = 0x0200_0001u;
string path = NewPath();
ulong key = PakKey.Compose(
PakAssetType.SetupCollision,
setupId);
using (var writer = new PakWriter(path, Header()))
{
writer.AddBlob(
key,
FlatCollisionAssetSerializer.Serialize(SetupAsset()));
writer.Finish();
}
using (var reader = new PakReader(path))
{
Assert.Equal(
PakObjectReadStatus.Missing,
reader.ReadObjectMeshData(key, out ObjectMeshData? mesh));
Assert.Null(mesh);
Assert.Equal(PakEntryState.Available, reader.ProbeEntry(key));
}
using var source = new PakPreparedAssetSource(path, Identity);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadSetupCollision(setupId).Status);
}
private static FlatGfxObjCollisionAsset GfxAsset() =>
new(
PhysicsBsp(),
new FlatCollisionSphere(
new Vector3(Float(0x3F80_0001), -0f, 3f),
Float(0x7FC0_1234)),
new FlatGfxObjVisualBounds(
new Vector3(-1, -2, -3),
new Vector3(4, 5, 6),
new Vector3(1.5f),
Float(0x4123_4567),
new Vector3(2.5f, 3.5f, 4.5f)));
private static FlatSetupCollision SetupAsset() =>
new(
ImmutableArray.Create(
new FlatCollisionCylinder(
new Vector3(1, 2, 3),
Float(0x3F00_0001),
Float(0x4000_0001))),
ImmutableArray.Create(
new FlatCollisionSphere(
new Vector3(-1, -2, -3),
Float(0x4040_0001))),
Float(0x4080_0001),
Float(0x40A0_0001),
Float(0x40C0_0001),
Float(0x40E0_0001));
private static FlatCellStructureCollisionAsset CellStructureAsset() =>
new(
PhysicsBsp(),
new FlatCellContainmentBsp(
0,
ImmutableArray.Create(
new FlatCellBspNode(
BSPNodeType.BPIN,
new Plane(Vector3.UnitX, -1),
1,
2,
0),
new FlatCellBspNode(
BSPNodeType.Leaf,
new Plane(Vector3.UnitY, -2),
-1,
-1,
1),
new FlatCellBspNode(
BSPNodeType.Leaf,
new Plane(Vector3.UnitZ, -3),
-1,
-1,
2))),
PolygonTable());
private static FlatEnvCellTopology TopologyAsset() =>
new(
ImmutableArray.Create(
new FlatEnvCellPortal(0x0102, 3, 4, 0),
new FlatEnvCellPortal(0x0103, 8, 2, 1)),
ImmutableArray.Create(0xA9B4_0101u, 0xA9B4_0102u),
true);
private static FlatPhysicsBsp PhysicsBsp() =>
new(
0,
ImmutableArray.Create(
Node(BSPNodeType.BPIN, 1, 2, 0, 0, 0),
Node(BSPNodeType.Leaf, -1, -1, 1, 0, 1),
Node(BSPNodeType.Leaf, -1, -1, 2, 1, 1)),
ImmutableArray.Create(0, 1),
PolygonTable());
private static FlatPhysicsBspNode Node(
BSPNodeType type,
int positive,
int negative,
int leaf,
int polygonStart,
int polygonCount) =>
new(
type,
new Plane(
new Vector3(1 + leaf, 2 + leaf, 3 + leaf),
Float(0x3F80_0000u + (uint)leaf)),
positive,
negative,
leaf,
leaf & 1,
new FlatCollisionSphere(
new Vector3(4 + leaf, 5 + leaf, 6 + leaf),
7 + leaf),
new FlatIndexRange(polygonStart, polygonCount));
private static FlatPolygonTable PolygonTable() =>
new(
ImmutableArray.Create(
new FlatCollisionPolygon(
3,
new Plane(Vector3.UnitX, -1),
CullMode.Clockwise,
3,
new FlatIndexRange(0, 3)),
new FlatCollisionPolygon(
8,
new Plane(Vector3.UnitY, -2),
CullMode.CounterClockwise,
3,
new FlatIndexRange(3, 3))),
ImmutableArray.Create(
Vector3.Zero,
Vector3.UnitY,
Vector3.UnitZ,
Vector3.One,
Vector3.UnitX,
Vector3.UnitZ));
private string NewPath()
{
string path = Path.Combine(
Path.GetTempPath(),
$"acdream-flat-collision-{Guid.NewGuid():N}.pak");
_paths.Add(path);
return path;
}
private static PakHeader Header() =>
new()
{
PortalIteration = Identity.PortalIteration,
CellIteration = Identity.CellIteration,
HighResIteration = Identity.HighResIteration,
LanguageIteration = Identity.LanguageIteration,
BakeToolVersion = Identity.BakeToolVersion,
};
private static void FlipFirstBlobByte(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
stream.Position = PakHeader.Size + 4;
int value = stream.ReadByte();
Assert.NotEqual(-1, value);
stream.Position = PakHeader.Size + 4;
stream.WriteByte((byte)(value ^ 0xFF));
}
private static float Float(uint bits) =>
BitConverter.Int32BitsToSingle(unchecked((int)bits));
private static void AssertFloatBits(float expected, float actual) =>
Assert.Equal(
BitConverter.SingleToInt32Bits(expected),
BitConverter.SingleToInt32Bits(actual));
public void Dispose()
{
foreach (string path in _paths)
{
try
{
File.Delete(path);
}
catch
{
// Best-effort cleanup.
}
}
}
}

View file

@ -0,0 +1,58 @@
using AcDream.Content.Pak;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.Content.Tests;
public sealed class InstalledPreparedCollisionCatalogTests
{
[Fact]
public void InstalledPackage_ContainsAndReadsCanonicalCollisionKeys()
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
string packagePath = Path.Combine(datDir, "acdream.pak");
if (!File.Exists(packagePath))
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
using var source =
new PakPreparedAssetSource(packagePath, adapter);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadGfxObjCollision(0x0100_0A2Bu).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadSetupCollision(0x0200_0001u).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadCellStructureCollision(0xA9B4_013Fu).Status);
Assert.Equal(
PreparedAssetReadStatus.Loaded,
source.ReadEnvCellTopology(0xA9B4_013Fu).Status);
Assert.Equal(4, source.CollisionStats.Loaded);
Assert.True(source.MappedVirtualBytes > 1L << 30);
}
private static string? ResolveDatDir()
{
string? configured =
Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(configured)
&& Directory.Exists(configured))
{
return configured;
}
string fallback = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(fallback) ? fallback : null;
}
}

View file

@ -35,6 +35,10 @@ public class PakKeyTests {
[InlineData(PakAssetType.GfxObjMesh, 1)] [InlineData(PakAssetType.GfxObjMesh, 1)]
[InlineData(PakAssetType.SetupMesh, 2)] [InlineData(PakAssetType.SetupMesh, 2)]
[InlineData(PakAssetType.EnvCellMesh, 3)] [InlineData(PakAssetType.EnvCellMesh, 3)]
[InlineData(PakAssetType.GfxObjCollision, 4)]
[InlineData(PakAssetType.SetupCollision, 5)]
[InlineData(PakAssetType.CellStructureCollision, 6)]
[InlineData(PakAssetType.EnvCellTopology, 7)]
public void AssetType_NumericValuesAreStable(PakAssetType type, byte expected) { public void AssetType_NumericValuesAreStable(PakAssetType type, byte expected) {
// These values are a wire format — pin them so a future refactor can't // These values are a wire format — pin them so a future refactor can't
// silently renumber the enum and corrupt existing paks. // silently renumber the enum and corrupt existing paks.

View file

@ -43,6 +43,24 @@ public sealed class FlatCollisionInstalledDatTests
FlatCollisionAssetBuilder.FlattenCell(source); FlatCollisionAssetBuilder.FlattenCell(source);
FlatCellCollisionAsset second = FlatCellCollisionAsset second =
FlatCollisionAssetBuilder.FlattenCell(source); FlatCollisionAssetBuilder.FlattenCell(source);
EnvCell datCell = Assert.IsType<EnvCell>(
dats.Get<EnvCell>(cellId));
var environment =
Assert.IsType<DatReaderWriter.DBObjs.Environment>(
dats.Get<DatReaderWriter.DBObjs.Environment>(
0x0D00_0000u | datCell.EnvironmentId));
Assert.True(
environment.Cells.TryGetValue(
datCell.CellStructure,
out CellStruct? cellStruct));
Assert.NotNull(cellStruct);
FlatCellStructureCollisionAsset directStructure =
FlatCollisionAssetBuilder.FlattenCellStructure(cellStruct!);
FlatEnvCellTopology directTopology =
FlatCollisionAssetBuilder.FlattenEnvCellTopology(
cellId,
datCell,
directStructure.PortalPolygons);
AssertPhysicsSourceMatches(source.BSP?.Root, source.Resolved, first.Structure.PhysicsBsp); AssertPhysicsSourceMatches(source.BSP?.Root, source.Resolved, first.Structure.PhysicsBsp);
AssertContainmentSourceMatches( AssertContainmentSourceMatches(
@ -60,6 +78,22 @@ public sealed class FlatCollisionInstalledDatTests
Assert.Equal( Assert.Equal(
first.Topology.VisibleCellIds.AsEnumerable(), first.Topology.VisibleCellIds.AsEnumerable(),
second.Topology.VisibleCellIds.AsEnumerable()); second.Topology.VisibleCellIds.AsEnumerable());
FlatCollisionAssetBuilderTests.AssertFlatPhysicsEqual(
first.Structure.PhysicsBsp,
directStructure.PhysicsBsp);
Assert.Equal(
first.Structure.ContainmentBsp.Nodes.AsEnumerable(),
directStructure.ContainmentBsp.Nodes.AsEnumerable());
AssertPolygonTableEqual(
first.Structure.PortalPolygons,
directStructure.PortalPolygons);
Assert.Equal(
first.Topology.Portals.AsEnumerable(),
directTopology.Portals.AsEnumerable());
Assert.Equal(
first.Topology.VisibleCellIds.AsEnumerable(),
directTopology.VisibleCellIds.AsEnumerable());
Assert.Equal(first.Topology.SeenOutside, directTopology.SeenOutside);
_output.WriteLine( _output.WriteLine(
$"cell 0x{cellId:X8}: physicsNodes={first.Structure.PhysicsBsp.Nodes.Length}, " + $"cell 0x{cellId:X8}: physicsNodes={first.Structure.PhysicsBsp.Nodes.Length}, " +
@ -97,6 +131,8 @@ public sealed class FlatCollisionInstalledDatTests
FlatCollisionAssetBuilder.FlattenGfxObj( FlatCollisionAssetBuilder.FlattenGfxObj(
source, source,
cache.GetVisualBounds(gfxObjId)); cache.GetVisualBounds(gfxObjId));
FlatGfxObjCollisionAsset direct =
FlatCollisionAssetBuilder.FlattenGfxObj(gfxObj);
AssertPhysicsSourceMatches( AssertPhysicsSourceMatches(
source.BSP.Root, source.BSP.Root,
@ -107,6 +143,15 @@ public sealed class FlatCollisionInstalledDatTests
AssertVisualBoundsBits( AssertVisualBoundsBits(
Assert.IsType<GfxObjVisualBounds>(cache.GetVisualBounds(gfxObjId)), Assert.IsType<GfxObjVisualBounds>(cache.GetVisualBounds(gfxObjId)),
flat.VisualBounds!.Value); flat.VisualBounds!.Value);
FlatCollisionAssetBuilderTests.AssertFlatPhysicsEqual(
flat.PhysicsBsp,
direct.PhysicsBsp);
AssertFlatNullableSphereBits(
flat.BoundingSphere,
direct.BoundingSphere);
AssertFlatNullableVisualBoundsBits(
flat.VisualBounds,
direct.VisualBounds);
_output.WriteLine( _output.WriteLine(
$"gfx 0x{gfxObjId:X8}: nodes={flat.PhysicsBsp.Nodes.Length}, " + $"gfx 0x{gfxObjId:X8}: nodes={flat.PhysicsBsp.Nodes.Length}, " +
@ -127,7 +172,10 @@ public sealed class FlatCollisionInstalledDatTests
FlatSetupCollision flat = FlatSetupCollision flat =
FlatCollisionAssetBuilder.FlattenSetup(source); FlatCollisionAssetBuilder.FlattenSetup(source);
FlatSetupCollision direct =
FlatCollisionAssetBuilder.FlattenSetup(setup);
AssertSetupSourceMatches(source, flat); AssertSetupSourceMatches(source, flat);
AssertSetupFlatEqual(flat, direct);
_output.WriteLine( _output.WriteLine(
$"setup 0x{setupId:X8}: cylinders={flat.Cylinders.Length}, " + $"setup 0x{setupId:X8}: cylinders={flat.Cylinders.Length}, " +
@ -327,4 +375,114 @@ public sealed class FlatCollisionInstalledDatTests
source.HalfExtents, source.HalfExtents,
flat.HalfExtents); flat.HalfExtents);
} }
private static void AssertFlatNullableSphereBits(
FlatCollisionSphere? expected,
FlatCollisionSphere? actual)
{
Assert.Equal(expected.HasValue, actual.HasValue);
if (!expected.HasValue || !actual.HasValue)
return;
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Value.Origin,
actual.Value.Origin);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Value.Radius,
actual.Value.Radius);
}
private static void AssertFlatNullableVisualBoundsBits(
FlatGfxObjVisualBounds? expected,
FlatGfxObjVisualBounds? actual)
{
Assert.Equal(expected.HasValue, actual.HasValue);
if (!expected.HasValue || !actual.HasValue)
return;
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Value.Min,
actual.Value.Min);
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Value.Max,
actual.Value.Max);
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Value.Center,
actual.Value.Center);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Value.Radius,
actual.Value.Radius);
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Value.HalfExtents,
actual.Value.HalfExtents);
}
private static void AssertSetupFlatEqual(
FlatSetupCollision expected,
FlatSetupCollision actual)
{
Assert.Equal(expected.Cylinders.Length, actual.Cylinders.Length);
Assert.Equal(expected.Spheres.Length, actual.Spheres.Length);
for (int i = 0; i < expected.Cylinders.Length; i++)
{
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Cylinders[i].Origin,
actual.Cylinders[i].Origin);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Cylinders[i].Radius,
actual.Cylinders[i].Radius);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Cylinders[i].Height,
actual.Cylinders[i].Height);
}
for (int i = 0; i < expected.Spheres.Length; i++)
{
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Spheres[i].Origin,
actual.Spheres[i].Origin);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Spheres[i].Radius,
actual.Spheres[i].Radius);
}
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Height,
actual.Height);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.Radius,
actual.Radius);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.StepUpHeight,
actual.StepUpHeight);
FlatCollisionAssetBuilderTests.AssertFloatBits(
expected.StepDownHeight,
actual.StepDownHeight);
}
private static void AssertPolygonTableEqual(
FlatPolygonTable expected,
FlatPolygonTable actual)
{
Assert.Equal(expected.Polygons.Length, actual.Polygons.Length);
Assert.Equal(expected.Vertices.Length, actual.Vertices.Length);
for (int i = 0; i < expected.Polygons.Length; i++)
{
Assert.Equal(expected.Polygons[i].Id, actual.Polygons[i].Id);
Assert.Equal(
expected.Polygons[i].SidesType,
actual.Polygons[i].SidesType);
Assert.Equal(
expected.Polygons[i].NumPoints,
actual.Polygons[i].NumPoints);
Assert.Equal(
expected.Polygons[i].VertexRange,
actual.Polygons[i].VertexRange);
FlatCollisionAssetBuilderTests.AssertPlaneBits(
expected.Polygons[i].Plane,
actual.Polygons[i].Plane);
}
for (int i = 0; i < expected.Vertices.Length; i++)
{
FlatCollisionAssetBuilderTests.AssertVectorBits(
expected.Vertices[i],
actual.Vertices[i]);
}
}
} }