feat(streaming): shadow-publish flat collision assets

Carry one immutable prepared collision closure with each accepted near-tier generation and install graph plus flat views through the same retained publication receipt. Apply the same strict package-only rule to live entities, add exact sampled graph-authoritative comparison artifacts and lifecycle counters, and prove cancellation, demotion, rehydrate, revisit, teardown, reconnect, and the nine-stop route with 14,064 zero-mismatch samples.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 16:38:54 +02:00
parent f7ff9f4eea
commit d9446030e6
32 changed files with 2777 additions and 92 deletions

View file

@ -174,11 +174,17 @@ six-path moving-collision entry point has an integer-indexed flat shadow port.
The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons, The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons,
1,200 complete resolver frames, and 10,000 warmed iterations at zero managed 1,200 complete resolver frames, and 10,000 warmed iterations at zero managed
bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass. bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass.
Production remains graph-authoritative until the connected shadow and cutover Production remains graph-authoritative until the Slice I6 cutover.
gates. Slice I5 is complete: immutable near-build collision closures and strict live
collision publication install graph and prepared-flat views under canonical
ownership. Cancellation, demotion, rehydration, revisit, removal, and reset
gates pass. The lifecycle/reconnect and nine-stop routes sampled 14,064 exact
queries with zero mismatch, fault, or artifact; graph/flat cell residency
matched at every stable checkpoint and teardown converged. Release build and
8,396 solution tests / 5 skips 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 I5 from passes. Slice I is active at I6 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:
@ -187,7 +193,8 @@ the portal-warmup corrections. Evidence:
`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` and `docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and `docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and
`docs/research/2026-07-25-slice-i4-flat-bsp-differential.md`. `docs/research/2026-07-25-slice-i4-flat-bsp-differential.md` and
`docs/research/2026-07-25-slice-i5-dual-collision-shadow.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

@ -172,11 +172,17 @@ six-path moving-collision entry point has an integer-indexed flat shadow port.
The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons, The exact referee passes 13 focused tests, 7,500 installed-DAT comparisons,
1,200 complete resolver frames, and 10,000 warmed iterations at zero managed 1,200 complete resolver frames, and 10,000 warmed iterations at zero managed
bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass. bytes with no mismatch. Release build and 8,384 solution tests / 5 skips pass.
Production remains graph-authoritative until the connected shadow and cutover Production remains graph-authoritative until the Slice I6 cutover.
gates. Slice I5 is complete: immutable near-build collision closures and strict live
collision publication install graph and prepared-flat views under canonical
ownership. Cancellation, demotion, rehydration, revisit, removal, and reset
gates pass. The lifecycle/reconnect and nine-stop routes sampled 14,064 exact
queries with zero mismatch, fault, or artifact; graph/flat cell residency
matched at every stable checkpoint and teardown converged. Release build and
8,396 solution tests / 5 skips 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 I5 from passes. Slice I is active at I6 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:
@ -185,7 +191,8 @@ the portal-warmup corrections. Evidence:
`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` and `docs/research/2026-07-25-slice-i2-flat-collision-schema.md` and
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and `docs/research/2026-07-25-slice-i3-prepared-collision-package.md` and
`docs/research/2026-07-25-slice-i4-flat-bsp-differential.md`. `docs/research/2026-07-25-slice-i4-flat-bsp-differential.md` and
`docs/research/2026-07-25-slice-i5-dual-collision-shadow.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

@ -144,6 +144,17 @@ EnvCell topology remains independently keyed. Production traversal is still
the parsed graph oracle until Slice I6. Full-catalog evidence: the parsed graph oracle until Slice I6. Full-catalog evidence:
`docs/research/2026-07-25-slice-i3-prepared-collision-package.md`. `docs/research/2026-07-25-slice-i3-prepared-collision-package.md`.
**Slice I5 dual-publication seam (2026-07-25).** Near-tier
`LandblockBuild` payloads now carry one immutable prepared-collision closure
outside the DAT lock. `LandblockPhysicsPublisher` installs the parsed oracle
and flat view under the same retained receipt; live objects use the strict
`LiveCollisionAssetPublisher`. Cell/topology ownership is landblock-scoped and
withdrawn on replacement, demotion, removal, and reset. The connected
graph-authoritative referee completed 14,064 exact samples with no mismatch or
fault. This is still one `DatCollection` and one prepared-package mmap, not a
second reader or a WorldBuilder runtime dependency. Evidence:
`docs/research/2026-07-25-slice-i5-dual-collision-shadow.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

@ -1661,13 +1661,18 @@ port in any phase — no separate listing here.
> installed-DAT comparisons, 1,200 complete resolver frames, and 10,000 warmed > installed-DAT comparisons, 1,200 complete resolver frames, and 10,000 warmed
> iterations at zero managed bytes with no mismatch. Production remains > iterations at zero managed bytes with no mismatch. Production remains
> graph-authoritative while I5 dual publication and the connected shadow gate > graph-authoritative while I5 dual publication and the connected shadow gate
> are active. Release build and 8,384 solution tests / 5 skips pass. > execute. I5 is now complete: immutable near-build collision closures and the
> strict live-object publisher install both representations under canonical
> ownership. Lifecycle/reconnect plus the nine-stop route sampled 14,064
> queries with exact state/float-bit equality, zero mismatch/fault/artifact,
> equal graph/flat cell residency, and graceful teardown. Release build and
> 8,396 solution tests / 5 skips pass. I6 production cutover is active.
> The historical exact cutover > The historical exact cutover
> rollback remains > rollback remains
> `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete:
> retained UI/frame work, exact bounded light selection, and > retained UI/frame work, exact bounded light selection, and
> pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the > pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the
> connected lifecycle/reconnect gate. Slice I is active at I5 from > connected lifecycle/reconnect gate. Slice I is active at I6 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
@ -1692,11 +1697,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, its deterministic prepared package, and the zero-mismatch/zero-allocation flat traversal referee are shipped; connected dual publication continues in Slice I5. | | 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, deterministic package, zero-allocation traversal, strict dual publication, and the 14,064-sample connected shadow gate are shipped; production flat cutover continues in Slice I6. |
| 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, I2 immutable schema, I3 deterministic collision package, and I4 exact flat traversal referee shipped; I5 dual publication and connected shadow verification are active. | | MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch, I2 immutable schema, I3 deterministic collision package, I4 exact flat traversal, and I5 strict dual publication plus connected shadow verification shipped; I6 production cutover is active. |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |
--- ---

View file

@ -134,8 +134,14 @@ randomized static/swept comparisons, 5,000 walkable comparisons, 7,500
installed-DAT comparisons, and 1,200 complete resolver frames have exact installed-DAT comparisons, and 1,200 complete resolver frames have exact
state/float-bit equality; 10,000 warmed flat iterations allocate zero managed state/float-bit equality; 10,000 warmed flat iterations allocate zero managed
bytes. Release build and 8,384 solution tests / 5 skips pass. bytes. Release build and 8,384 solution tests / 5 skips pass.
Slice H is complete: retained UI/frame work, exact bounded light selection, Slice I5 is complete: immutable near-build collision closures and strict live
and pooled/borrowed/direct network I/O pass. Slice I is active at I5 under collision publication install graph and prepared-flat views together. The
lifecycle/reconnect and nine-stop connected routes sampled 14,064 exact
queries with zero mismatch, fault, or artifact; graph/flat cell residency
matched at every stable checkpoint and teardown converged. Release build and
8,396 solution tests / 5 skips pass. 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 I6 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

@ -1,8 +1,9 @@
# Modern runtime Slice I — flat collision assets and zero-allocation physics # Modern runtime Slice I — flat collision assets and zero-allocation physics
**Status:** I0-I4 COMPLETE - retail oracle, zero-allocation transition **Status:** I0-I5 COMPLETE - retail oracle, zero-allocation transition
scratch, deterministic immutable collision records, complete prepared scratch, deterministic immutable collision records, complete prepared
package, and exact flat-traversal differential gate fixed; I5 active package, exact flat traversal, dual publication, and connected shadow gate;
I6 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
@ -241,6 +242,17 @@ until I5/I6. Release build and 8,384 solution tests / 5 skips pass. Evidence:
Gate: zero shadow mismatch and no retained collision owner after landblock or Gate: zero shadow mismatch and no retained collision owner after landblock or
session teardown. session teardown.
**Complete 2026-07-25.** Near-tier builds carry one immutable prepared
collision closure; streamed and live objects publish graph and flat assets
strictly, with no graph-only fallback. Cancellation, publication, demotion,
rehydration, revisit, adjacent-landblock isolation, removal, and session
teardown gates pass. The complete Release solution passes 8,396 tests / 5
skips. The connected lifecycle/reconnect and nine-stop routes sampled 14,064
queries with exact state/float-bit equality, zero mismatch, zero flat fault,
zero mismatch artifact, equal graph/flat cell residency at every stable
checkpoint, and graceful exits. Evidence:
[`../research/2026-07-25-slice-i5-dual-collision-shadow.md`](../research/2026-07-25-slice-i5-dual-collision-shadow.md).
### I6 — production cutover and graph removal ### I6 — production cutover and graph removal
1. Flip canonical `PhysicsDataCache` records and every `BSPQuery` production 1. Flip canonical `PhysicsDataCache` records and every `BSPQuery` production

View file

@ -0,0 +1,91 @@
# Slice I5 — dual collision publication and connected shadow gate
## Outcome
Slice I5 is complete. Production gameplay remains graph-authoritative for this
checkpoint, but every streamed near-tier generation and every live DAT object
now publishes the matching prepared flat collision representation. The sampled
flat path runs from a complete cloned transition, compares exact state and
float bits, and cannot alter the graph result used by gameplay.
No visual, collision, movement, or retail algorithm changed in this slice.
## Ownership
- `LandblockBuild` carries one immutable `LandblockCollisionBuild` containing
sorted source IDs, flat GfxObj/Setup collision, flat CellStruct geometry, and
per-EnvCell topology.
- `LandblockBuildFactory` reads that closure from the existing
`IPreparedCollisionSource` after the serialized DAT transaction. A missing or
corrupt prepared record rejects the complete near-tier generation; a
graph-only partial publication is forbidden.
- `LandblockPhysicsPublisher` commits graph and flat views through one retained
publication receipt. Cell and topology ownership is withdrawn on exact
replacement, near-to-far demotion, full removal, and session teardown.
- `LiveCollisionAssetPublisher` applies the same strict dual-publication rule
to CreateObject, appearance, and local-player Setup paths.
- `PhysicsDataCache` exposes typed graph/flat counts in canonical lifecycle
snapshots. Landblock-scoped cell and topology catalogs retire together.
## Shadow referee
`CollisionShadowVerifier` samples the production entry points while leaving the
graph result authoritative. The flat query receives one retained, reset-complete
clone of `ObjectInfo`, `SpherePath`, and `CollisionInfo`. Comparisons cover:
- containment availability, point containment, sphere containment;
- root bounding spheres and physics availability;
- cell and GfxObj moving-collision results;
- complete transition state, collision/contact state, sphere path, candidate
ordering, walkable vertices, GUID lists, and exact IEEE-754 float bits.
A mismatch or flat fault writes one deterministic JSON artifact. The disabled
production path allocates zero managed bytes over 10,000 warmed queries.
## Automated evidence
- Focused Core and App collision/publication suites pass.
- Cancellation publishes neither representation.
- Pending receipt to accepted publication, demotion, rehydration,
same-location replacement, adjacent-landblock isolation, full removal, and
session reset preserve exact ownership without duplication.
- Release build: zero warnings and zero errors.
- Complete solution: 8,396 passed / 5 skipped.
## Connected evidence
Lifecycle/reconnect gate:
- artifact:
`logs/connected-world-gate-20260725-162020/report.json`
- capped login, Aerlinthe, Rynthid, Facility Hub, Holtburg, Aerlinthe revisit,
then a fresh uncapped reconnect process;
- 12,730 sampled queries, 12,730 exact matches;
- zero mismatches, zero flat faults, zero mismatch artifacts;
- graph/flat cell counts equal at every stable checkpoint;
- all publication, retirement, upload, and warmup backlogs converged to zero;
- both processes disconnected gracefully with exit code 0.
Nine-stop R6 route:
- artifact:
`logs/connected-r6-soak-20260725-162614.report.json`
- Caul, Sawato, Rynthid, Aerlinthe, Sawato revisit, Holtburg, Caul revisit,
Sawato plateau, and Caul plateau;
- turn, forward/release, jump, combat-toggle, and movement-truth exercises;
- 42,719 eligible queries, 1,334 sampled queries, 1,334 exact matches;
- zero mismatches, zero flat faults, zero mismatch artifacts;
- graph/flat cell counts equal at all nine stable checkpoints;
- graceful client shutdown and zero gate failures.
The run occurred under RDP/lock-screen conditions. It is accepted for
deterministic correctness and lifetime evidence only; its absolute frame/GPU
measurements are not a physical-display performance gate.
## Next
Slice I6 flips canonical production traversal to the prepared flat
representation in one bisectable commit. The graph remains a temporary
automated referee for the initial cutover gate. The exact cutover commit and
`git revert <sha>` command must be recorded before correctness/visual
acceptance, after which production graph retention can be removed.

View file

@ -30,6 +30,7 @@ internal sealed record ContentEffectsAudioResult(
MagicCatalog MagicCatalog, MagicCatalog MagicCatalog,
IAnimationLoader AnimationLoader, IAnimationLoader AnimationLoader,
LiveEntityCollisionBuilder CollisionBuilder, LiveEntityCollisionBuilder CollisionBuilder,
LiveCollisionAssetPublisher CollisionAssets,
EmitterDescRegistry EmitterRegistry, EmitterDescRegistry EmitterRegistry,
ParticleSystem ParticleSystem, ParticleSystem ParticleSystem,
ParticleHookSink ParticleSink, ParticleHookSink ParticleSink,
@ -95,6 +96,9 @@ internal interface IContentEffectsAudioCompositionFactory
IDatReaderWriter dats, IDatReaderWriter dats,
IAnimationLoader animationLoader, IAnimationLoader animationLoader,
bool dumpMotionEnabled); bool dumpMotionEnabled);
LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets);
EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats); EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats);
ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters); ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters);
ParticleHookSink CreateParticleSink( ParticleHookSink CreateParticleSink(
@ -165,6 +169,15 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
animationLoader, animationLoader,
dumpMotionEnabled)); dumpMotionEnabled));
public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets) =>
new(
physicsData,
preparedAssets as IPreparedCollisionSource
?? throw new NotSupportedException(
"Production prepared assets must expose collision data."));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) =>
new(dats); new(dats);
@ -332,6 +345,10 @@ internal sealed class ContentEffectsAudioCompositionPhase :
_dependencies.DumpMotionEnabled); _dependencies.DumpMotionEnabled);
_publication.PublishLiveEntityCollisionBuilder(collision); _publication.PublishLiveEntityCollisionBuilder(collision);
Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished); Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished);
LiveCollisionAssetPublisher collisionAssets =
_factory.CreateLiveCollisionAssetPublisher(
_dependencies.PhysicsDataCache,
preparedAssets);
EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats); EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats);
_publication.PublishEmitterRegistry(emitters); _publication.PublishEmitterRegistry(emitters);
@ -403,6 +420,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
magic, magic,
animations, animations,
collision, collision,
collisionAssets,
emitters, emitters,
particles, particles,
particleSink, particleSink,

View file

@ -441,6 +441,9 @@ internal sealed class FrameRootCompositionPhase
d.FrameProfiler, d.FrameProfiler,
content.Dats, content.Dats,
foundation.Residency, foundation.Residency,
d.PhysicsEngine.DataCache
?? throw new InvalidOperationException(
"Lifecycle automation requires the canonical physics cache."),
currentRenderSceneOracle, currentRenderSceneOracle,
renderSceneShadowComparison, renderSceneShadowComparison,
renderFrameProduct); renderFrameProduct);

View file

@ -12,6 +12,7 @@ using AcDream.App.Settings;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.Update; using AcDream.App.Update;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Chat; using AcDream.Core.Chat;
@ -252,11 +253,16 @@ internal sealed class SessionPlayerCompositionPhase
$"(window={2 * farRadius + 1}x{2 * farRadius + 1})"); $"(window={2 * farRadius + 1}x{2 * farRadius + 1})");
Fault(SessionPlayerCompositionPoint.StreamingRadiiResolved); Fault(SessionPlayerCompositionPoint.StreamingRadiiResolved);
IPreparedCollisionSource preparedCollisions =
content.PreparedAssets as IPreparedCollisionSource ??
throw new NotSupportedException(
"Production prepared assets must expose the matching " +
"prepared-collision catalog.");
var landblockBuildFactory = new LandblockBuildFactory( var landblockBuildFactory = new LandblockBuildFactory(
content.Dats, content.Dats,
preparedCollisions,
d.DatLock, d.DatLock,
world.TerrainBuild.HeightTable, world.TerrainBuild.HeightTable,
d.PhysicsDataCache,
d.Options.DumpSceneryZ); d.Options.DumpSceneryZ);
var streamerLease = scope.Acquire( var streamerLease = scope.Acquire(
"landblock streamer", "landblock streamer",
@ -399,7 +405,7 @@ internal sealed class SessionPlayerCompositionPhase
d.Options, d.Options,
content.Dats, content.Dats,
live.LiveEntities, live.LiveEntities,
d.PhysicsDataCache, content.CollisionAssets,
content.AnimationLoader, content.AnimationLoader,
live.EntitySpawnAdapter, live.EntitySpawnAdapter,
world.Foundation.TextureCache, world.Foundation.TextureCache,
@ -689,7 +695,7 @@ internal sealed class SessionPlayerCompositionPhase
d.MotionBindings, d.MotionBindings,
content.Dats, content.Dats,
d.DatLock, d.DatLock,
d.PhysicsDataCache, content.CollisionAssets,
d.AnimatedEntities, d.AnimatedEntities,
localPlayerAnimation, localPlayerAnimation,
localPlayerShadow, localPlayerShadow,

View file

@ -4,6 +4,7 @@ using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene; using AcDream.App.Rendering.Scene;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.UI.Testing; using AcDream.App.UI.Testing;
using AcDream.Core.Physics;
namespace AcDream.App.Diagnostics; namespace AcDream.App.Diagnostics;
@ -81,6 +82,14 @@ internal sealed record WorldLifecycleResourceSnapshot(
long DatObjectCacheHits, long DatObjectCacheHits,
long DatObjectCacheMisses, long DatObjectCacheMisses,
long DatObjectCacheEvictions, long DatObjectCacheEvictions,
int PhysicsGraphGfxObjs,
int PhysicsGraphSetups,
int PhysicsGraphCells,
int PhysicsFlatGfxObjs,
int PhysicsFlatSetups,
int PhysicsFlatCells,
int PhysicsFlatEnvCells,
CollisionShadowStats CollisionShadow,
StreamingWorkDiagnostics StreamingWork, StreamingWorkDiagnostics StreamingWork,
ResidencySnapshot Residency, ResidencySnapshot Residency,
double Fps, double Fps,

View file

@ -6,6 +6,7 @@ using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Physics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using DatReaderWriter.Lib.IO; using DatReaderWriter.Lib.IO;
@ -39,6 +40,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
private readonly FrameProfiler _frameProfiler; private readonly FrameProfiler _frameProfiler;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly ResidencyManager _residency; private readonly ResidencyManager _residency;
private readonly PhysicsDataCache _physics;
private readonly ICurrentRenderSceneOracleSnapshotSource? private readonly ICurrentRenderSceneOracleSnapshotSource?
_renderSceneOracle; _renderSceneOracle;
private readonly IRenderSceneShadowSnapshotSource? private readonly IRenderSceneShadowSnapshotSource?
@ -63,6 +65,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
FrameProfiler frameProfiler, FrameProfiler frameProfiler,
IDatReaderWriter dats, IDatReaderWriter dats,
ResidencyManager residency, ResidencyManager residency,
PhysicsDataCache physics,
ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null, ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null,
IRenderSceneShadowSnapshotSource? renderSceneShadow = null, IRenderSceneShadowSnapshotSource? renderSceneShadow = null,
IRenderFrameProductSnapshotSource? renderFrameProduct = null) IRenderFrameProductSnapshotSource? renderFrameProduct = null)
@ -92,6 +95,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_residency = residency _residency = residency
?? throw new ArgumentNullException(nameof(residency)); ?? throw new ArgumentNullException(nameof(residency));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
_renderSceneOracle = renderSceneOracle; _renderSceneOracle = renderSceneOracle;
_renderSceneShadow = renderSceneShadow; _renderSceneShadow = renderSceneShadow;
_renderFrameProduct = renderFrameProduct; _renderFrameProduct = renderFrameProduct;
@ -184,6 +188,14 @@ internal sealed class WorldLifecycleResourceSnapshotSource
DatObjectCacheHits: datObjectCacheStats.Hits, DatObjectCacheHits: datObjectCacheStats.Hits,
DatObjectCacheMisses: datObjectCacheStats.Misses, DatObjectCacheMisses: datObjectCacheStats.Misses,
DatObjectCacheEvictions: datObjectCacheStats.Evictions, DatObjectCacheEvictions: datObjectCacheStats.Evictions,
PhysicsGraphGfxObjs: _physics.GfxObjCount,
PhysicsGraphSetups: _physics.SetupCount,
PhysicsGraphCells: _physics.CellStructCount,
PhysicsFlatGfxObjs: _physics.FlatGfxObjCount,
PhysicsFlatSetups: _physics.FlatSetupCount,
PhysicsFlatCells: _physics.FlatCellStructCount,
PhysicsFlatEnvCells: _physics.FlatEnvCellCount,
CollisionShadow: _physics.CollisionShadowStats,
StreamingWork: _streaming.WorkDiagnostics, StreamingWork: _streaming.WorkDiagnostics,
Residency: residency, Residency: residency,
Fps: render.Fps, Fps: render.Fps,

View file

@ -33,7 +33,7 @@ internal sealed class PlayerModeController :
private readonly ILiveEntityMotionRuntimeBindings _motionBindings; private readonly ILiveEntityMotionRuntimeBindings _motionBindings;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly object _datLock; private readonly object _datLock;
private readonly PhysicsDataCache _physicsDataCache; private readonly LiveCollisionAssetPublisher _collisionAssets;
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations; private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animations;
private readonly LocalPlayerAnimationController _animation; private readonly LocalPlayerAnimationController _animation;
private readonly LocalPlayerShadowSynchronizer _shadow; private readonly LocalPlayerShadowSynchronizer _shadow;
@ -59,7 +59,7 @@ internal sealed class PlayerModeController :
ILiveEntityMotionRuntimeBindings motionBindings, ILiveEntityMotionRuntimeBindings motionBindings,
IDatReaderWriter dats, IDatReaderWriter dats,
object datLock, object datLock,
PhysicsDataCache physicsDataCache, LiveCollisionAssetPublisher collisionAssets,
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations, LiveEntityAnimationRuntimeView<LiveEntityAnimationState> animations,
LocalPlayerAnimationController animation, LocalPlayerAnimationController animation,
LocalPlayerShadowSynchronizer shadow, LocalPlayerShadowSynchronizer shadow,
@ -82,7 +82,8 @@ internal sealed class PlayerModeController :
_motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings)); _motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings));
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); _collisionAssets = collisionAssets ??
throw new ArgumentNullException(nameof(collisionAssets));
_animations = animations ?? throw new ArgumentNullException(nameof(animations)); _animations = animations ?? throw new ArgumentNullException(nameof(animations));
_animation = animation ?? throw new ArgumentNullException(nameof(animation)); _animation = animation ?? throw new ArgumentNullException(nameof(animation));
_shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
@ -536,7 +537,9 @@ internal sealed class PlayerModeController :
setup = _dats.Get<DatReaderWriter.DBObjs.Setup>( setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(
playerEntity.SourceGfxObjOrSetupId); playerEntity.SourceGfxObjOrSetupId);
if (setup is not null) if (setup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup); _collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
controller.StepUpHeight = setup is { StepUpHeight: > 0f } controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight ? setup.StepUpHeight
: 0.4f; : 0.4f;

View file

@ -0,0 +1,68 @@
using AcDream.Content;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Physics;
/// <summary>
/// Publishes the graph and prepared collision views for DAT objects discovered
/// through the live-object path. Streamed statics carry their prepared closure
/// on <c>LandblockBuild</c>; live CreateObject/appearance data arrives outside
/// that transaction and therefore uses this matching strict publisher.
/// </summary>
internal sealed class LiveCollisionAssetPublisher
{
private readonly PhysicsDataCache _cache;
private readonly IPreparedCollisionSource _source;
public LiveCollisionAssetPublisher(
PhysicsDataCache cache,
IPreparedCollisionSource source)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_source = source ?? throw new ArgumentNullException(nameof(source));
}
public void CacheGfxObj(uint id, GfxObj gfxObj)
{
ArgumentNullException.ThrowIfNull(gfxObj);
FlatGfxObjCollisionAsset? prepared =
_cache.GetFlatGfxObj(id) is not null
? null
: Require(
_source.ReadGfxObjCollision(id),
"GfxObj collision",
id);
_cache.CacheGfxObj(id, gfxObj, prepared);
}
public void CacheSetup(uint id, Setup setup)
{
ArgumentNullException.ThrowIfNull(setup);
FlatSetupCollision? prepared =
_cache.GetFlatSetup(id) is not null
? null
: Require(
_source.ReadSetupCollision(id),
"Setup collision",
id);
_cache.CacheSetup(id, setup, prepared);
}
private static T Require<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded &&
result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. " +
"Live collision cannot publish only one representation.");
}
}

View file

@ -30,7 +30,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
private readonly RuntimeOptions _options; private readonly RuntimeOptions _options;
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly LiveEntityRuntime _runtime; private readonly LiveEntityRuntime _runtime;
private readonly PhysicsDataCache _physicsData; private readonly LiveCollisionAssetPublisher _collisionAssets;
private readonly IAnimationLoader _animationLoader; private readonly IAnimationLoader _animationLoader;
private readonly EntitySpawnAdapter _spawnAdapter; private readonly EntitySpawnAdapter _spawnAdapter;
private readonly TextureCache _textures; private readonly TextureCache _textures;
@ -62,7 +62,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
RuntimeOptions options, RuntimeOptions options,
IDatReaderWriter dats, IDatReaderWriter dats,
LiveEntityRuntime runtime, LiveEntityRuntime runtime,
PhysicsDataCache physicsData, LiveCollisionAssetPublisher collisionAssets,
IAnimationLoader animationLoader, IAnimationLoader animationLoader,
EntitySpawnAdapter spawnAdapter, EntitySpawnAdapter spawnAdapter,
TextureCache textures, TextureCache textures,
@ -82,7 +82,8 @@ internal sealed class DatLiveEntityProjectionMaterializer
_options = options ?? throw new ArgumentNullException(nameof(options)); _options = options ?? throw new ArgumentNullException(nameof(options));
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_physicsData = physicsData ?? throw new ArgumentNullException(nameof(physicsData)); _collisionAssets = collisionAssets ??
throw new ArgumentNullException(nameof(collisionAssets));
_animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader)); _animationLoader = animationLoader ?? throw new ArgumentNullException(nameof(animationLoader));
_spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter)); _spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter));
_textures = textures ?? throw new ArgumentNullException(nameof(textures)); _textures = textures ?? throw new ArgumentNullException(nameof(textures));
@ -172,7 +173,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
Setup? setup = _dats.Get<Setup>(canonicalSpawn.SetupTableId.Value); Setup? setup = _dats.Get<Setup>(canonicalSpawn.SetupTableId.Value);
if (setup is not null) if (setup is not null)
_physicsData.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); _collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup);
if (setup is null) if (setup is null)
{ {
_missingSetup++; _missingSetup++;
@ -282,7 +283,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
continue; continue;
} }
_physicsData.CacheGfxObj(part.GfxObjId, gfx); _collisionAssets.CacheGfxObj(part.GfxObjId, gfx);
if (dumpClothing) if (dumpClothing)
{ {
var subMeshes = GfxObjMesh.Build(gfx, _dats); var subMeshes = GfxObjMesh.Build(gfx, _dats);
@ -430,7 +431,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
} }
if (slotZeroGfx is not null) if (slotZeroGfx is not null)
_physicsData.CacheGfxObj(slotZeroId, slotZeroGfx); _collisionAssets.CacheGfxObj(slotZeroId, slotZeroGfx);
return slotZeroId; return slotZeroId;
} }
@ -512,7 +513,7 @@ internal sealed class DatLiveEntityProjectionMaterializer
} }
continue; continue;
} }
_physicsData.CacheGfxObj(parts[partIndex].GfxObjId, gfx); _collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx);
Dictionary<uint, uint>? resolved = null; Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces) foreach (var surfaceQid in gfx.Surfaces)

View file

@ -11,7 +11,8 @@ namespace AcDream.App.Streaming;
public sealed record LandblockBuild( public sealed record LandblockBuild(
LoadedLandblock Landblock, LoadedLandblock Landblock,
EnvCellLandblockBuild? EnvCells = null, EnvCellLandblockBuild? EnvCells = null,
LandblockBuildOrigin Origin = default) LandblockBuildOrigin Origin = default,
LandblockCollisionBuild? Collisions = null)
{ {
public uint LandblockId => Landblock.LandblockId; public uint LandblockId => Landblock.LandblockId;
} }

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using DatReaderWriter; using DatReaderWriter;
using AcDream.Content; using AcDream.Content;
@ -17,19 +18,21 @@ namespace AcDream.App.Streaming;
public sealed class LandblockBuildFactory public sealed class LandblockBuildFactory
{ {
private readonly IDatReaderWriter _dats; private readonly IDatReaderWriter _dats;
private readonly IPreparedCollisionSource _preparedCollisions;
private readonly object _datLock; private readonly object _datLock;
private readonly float[] _heightTable; private readonly float[] _heightTable;
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache;
private readonly bool _dumpSceneryZ; private readonly bool _dumpSceneryZ;
public LandblockBuildFactory( public LandblockBuildFactory(
IDatReaderWriter dats, IDatReaderWriter dats,
IPreparedCollisionSource preparedCollisions,
object datLock, object datLock,
float[] heightTable, float[] heightTable,
AcDream.Core.Physics.PhysicsDataCache physicsDataCache,
bool dumpSceneryZ = false) bool dumpSceneryZ = false)
{ {
_dats = dats ?? throw new ArgumentNullException(nameof(dats)); _dats = dats ?? throw new ArgumentNullException(nameof(dats));
_preparedCollisions = preparedCollisions ??
throw new ArgumentNullException(nameof(preparedCollisions));
_datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
ArgumentNullException.ThrowIfNull(heightTable); ArgumentNullException.ThrowIfNull(heightTable);
if (heightTable.Length < 256) if (heightTable.Length < 256)
@ -37,7 +40,6 @@ public sealed class LandblockBuildFactory
"The retail terrain height table must contain at least 256 entries.", "The retail terrain height table must contain at least 256 entries.",
nameof(heightTable)); nameof(heightTable));
_heightTable = (float[])heightTable.Clone(); _heightTable = (float[])heightTable.Clone();
_physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache));
_dumpSceneryZ = dumpSceneryZ; _dumpSceneryZ = dumpSceneryZ;
} }
@ -73,6 +75,7 @@ public sealed class LandblockBuildFactory
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build // lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
// cost). Identical work in both branches; the probe branch only adds the // cost). Identical work in both branches; the probe branch only adds the
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false. // stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
LandblockBuild? build;
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
@ -80,17 +83,28 @@ public sealed class LandblockBuildFactory
{ {
long waitedMs = sw.ElapsedMilliseconds; long waitedMs = sw.ElapsedMilliseconds;
sw.Restart(); sw.Restart();
var built = BuildLocked(request); build = BuildLocked(request);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"BUILD", request.LandblockId, "BUILD", request.LandblockId,
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}"); $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={request.Kind}");
return built;
} }
} }
lock (_datLock) else
{ {
return BuildLocked(request); lock (_datLock)
build = BuildLocked(request);
} }
if (build is null ||
request.Kind == LandblockStreamJobKind.LoadFar)
{
return build;
}
return build with
{
Collisions = BuildPreparedCollisionClosure(build.Landblock),
};
} }
private AcDream.App.Streaming.LandblockBuild? BuildLocked( private AcDream.App.Streaming.LandblockBuild? BuildLocked(
@ -143,7 +157,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null) if (gfx is not null)
{ {
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef( meshRefs.Add(new AcDream.Core.World.MeshRef(
@ -156,13 +169,11 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId); var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null) if (setup is not null)
{ {
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat) foreach (var mr in flat)
{ {
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue; if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr); meshRefs.Add(mr);
@ -293,6 +304,103 @@ public sealed class LandblockBuildFactory
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
} }
/// <summary>
/// Reads the exact prepared collision closure after the serialized DAT
/// transaction completes. The immutable package is independently
/// thread-safe, so these reads never extend the shared DAT-reader lock.
/// Missing or corrupt prepared data fails the complete near-tier build;
/// production must not publish a graph-only partial generation.
/// </summary>
private AcDream.Core.World.LandblockCollisionBuild
BuildPreparedCollisionClosure(
AcDream.Core.World.LoadedLandblock landblock)
{
AcDream.Core.World.PhysicsDatBundle dats =
landblock.PhysicsDats ??
AcDream.Core.World.PhysicsDatBundle.Empty;
ImmutableArray<uint> gfxObjIds = [.. dats.GfxObjs.Keys.Order()];
ImmutableArray<uint> setupIds = [.. dats.Setups.Keys.Order()];
ImmutableArray<uint> envCellIds = [.. dats.EnvCells.Keys.Order()];
var gfxObjs =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatGfxObjCollisionAsset>();
var setups =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatSetupCollision>();
var cellStructures =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatCellStructureCollisionAsset>();
var envCells =
ImmutableDictionary.CreateBuilder<
uint,
AcDream.Core.Physics.FlatEnvCellTopology>();
foreach (uint id in gfxObjIds)
{
gfxObjs.Add(
id,
RequirePrepared(
_preparedCollisions.ReadGfxObjCollision(id),
"GfxObj collision",
id));
}
foreach (uint id in setupIds)
{
setups.Add(
id,
RequirePrepared(
_preparedCollisions.ReadSetupCollision(id),
"Setup collision",
id));
}
foreach (uint id in envCellIds)
{
cellStructures.Add(
id,
RequirePrepared(
_preparedCollisions.ReadCellStructureCollision(id),
"CellStruct collision",
id));
envCells.Add(
id,
RequirePrepared(
_preparedCollisions.ReadEnvCellTopology(id),
"EnvCell topology",
id));
}
return new AcDream.Core.World.LandblockCollisionBuild(
gfxObjs.ToImmutable(),
setups.ToImmutable(),
cellStructures.ToImmutable(),
envCells.ToImmutable(),
gfxObjIds,
setupIds,
envCellIds);
}
private static T RequirePrepared<T>(
PreparedCollisionReadResult<T> result,
string kind,
uint id)
where T : class
{
if (result.Status == PreparedAssetReadStatus.Loaded &&
result.Data is not null)
{
return result.Data;
}
throw new InvalidDataException(
$"{kind} 0x{id:X8} is {result.Status}. " +
"The complete near-tier generation cannot be published.");
}
/// <summary> /// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls. /// landblock on the worker thread. Pure CPU — no GL calls.
@ -363,7 +471,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null) if (gfx is not null)
{ {
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
@ -374,13 +481,11 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId); var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null) if (setup is not null)
{ {
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat) foreach (var mr in flat)
{ {
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue; if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
// Compose: part's own transform, then the spawn's scale. // Compose: part's own transform, then the spawn's scale.
var partXf = mr.PartTransform * scaleMat; var partXf = mr.PartTransform * scaleMat;
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
@ -687,7 +792,6 @@ public sealed class LandblockBuildFactory
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null) if (gfx is not null)
{ {
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
@ -702,7 +806,6 @@ public sealed class LandblockBuildFactory
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id); var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
if (setup is not null) if (setup is not null)
{ {
_physicsDataCache.CacheSetup(stab.Id, setup);
stabLightCount = setup.Lights.Count; stabLightCount = setup.Lights.Count;
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
if (dumpStab) if (dumpStab)
@ -727,7 +830,6 @@ public sealed class LandblockBuildFactory
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
continue; continue;
} }
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
meshRefs.Add(mr); meshRefs.Add(mr);

View file

@ -46,6 +46,8 @@ public sealed class LandblockPhysicsPublication
internal int BuildingCursor { get; set; } internal int BuildingCursor { get; set; }
internal bool BaseCommitted { get; set; } internal bool BaseCommitted { get; set; }
internal int GfxCursor { get; set; } internal int GfxCursor { get; set; }
internal uint[] SetupObjectIds { get; set; } = Array.Empty<uint>();
internal int SetupCursor { get; set; }
internal int PriorStaticCursor { get; set; } internal int PriorStaticCursor { get; set; }
internal int StaticCursor { get; set; } internal int StaticCursor { get; set; }
internal int BspOwnerCount { get; set; } internal int BspOwnerCount { get; set; }
@ -199,7 +201,7 @@ public sealed class LandblockPhysicsPublisher
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>(); ?? Array.Empty<BuildingInfo>();
return new LandblockPhysicsPublication( var publication = new LandblockPhysicsPublication(
_receiptOwner, _receiptOwner,
build, build,
origin, origin,
@ -207,6 +209,10 @@ public sealed class LandblockPhysicsPublisher
buildings, buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId)); build.Landblock.LandblockId));
publication.SetupObjectIds = build.Collisions is { } collisions
? [.. collisions.SetupIds]
: datBundle.Setups.Keys.Order().ToArray();
return publication;
} }
/// <summary> /// <summary>
@ -223,6 +229,14 @@ public sealed class LandblockPhysicsPublisher
IReadOnlyList<WorldEntity> entities = IReadOnlyList<WorldEntity> entities =
publication.Build.Landblock.Entities; publication.Build.Landblock.Entities;
if (publication.Build.Collisions is { } collisions)
{
publication.GfxObjectIds = [.. collisions.GfxObjIds];
publication.PreparationCursor = entities.Count;
publication.PreparationCommitted = true;
return true;
}
if (publication.PreparationCursor < entities.Count) if (publication.PreparationCursor < entities.Count)
{ {
WorldEntity entity = entities[publication.PreparationCursor]; WorldEntity entity = entities[publication.PreparationCursor];
@ -389,10 +403,33 @@ public sealed class LandblockPhysicsPublisher
long cacheStarted = Stopwatch.GetTimestamp(); long cacheStarted = Stopwatch.GetTimestamp();
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor]; uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx)) if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx))
_physicsDataCache.CacheGfxObj(gfxObjectId, gfx); {
FlatGfxObjCollisionAsset? prepared = null;
publication.Build.Collisions?.GfxObjs.TryGetValue(
gfxObjectId,
out prepared);
_physicsDataCache.CacheGfxObj(
gfxObjectId,
gfx,
prepared);
}
publication.GfxCursor++; publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
} }
else if (publication.SetupCursor < publication.SetupObjectIds.Length)
{
uint setupId =
publication.SetupObjectIds[publication.SetupCursor];
if (datBundle.Setups.TryGetValue(setupId, out var setup))
{
FlatSetupCollision? prepared = null;
publication.Build.Collisions?.Setups.TryGetValue(
setupId,
out prepared);
_physicsDataCache.CacheSetup(setupId, setup, prepared);
}
publication.SetupCursor++;
}
else if (publication.PriorStaticCursor else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length) < publication.PriorStaticOwnerIds.Length)
{ {
@ -486,11 +523,21 @@ public sealed class LandblockPhysicsPublisher
Matrix4x4.CreateFromQuaternion(rotation) Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld); * Matrix4x4.CreateTranslation(cellOriginWorld);
FlatCellStructureCollisionAsset? preparedStructure = null;
FlatEnvCellTopology? preparedTopology = null;
publication.Build.Collisions?.CellStructures.TryGetValue(
envCellId,
out preparedStructure);
publication.Build.Collisions?.EnvCells.TryGetValue(
envCellId,
out preparedTopology);
_physicsDataCache.CacheCellStruct( _physicsDataCache.CacheCellStruct(
envCellId, envCellId,
envCell, envCell,
cellStruct, cellStruct,
physicsCellTransform); physicsCellTransform,
preparedStructure,
preparedTopology);
var worldVertices = new Dictionary<ushort, Vector3>( var worldVertices = new Dictionary<ushort, Vector3>(
cellStruct.VertexArray.Vertices.Count); cellStruct.VertexArray.Vertices.Count);

View file

@ -0,0 +1,555 @@
using System.Globalization;
using System.Numerics;
using System.Text.Json;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics;
public readonly record struct CollisionShadowStats(
long Queries,
long Samples,
long Matches,
long Mismatches,
long Faults);
/// <summary>
/// Slice I5 sampled graph/flat referee. The graph result always remains
/// authoritative. One retained clone receives the flat query first; the
/// canonical transition is then evaluated by the graph path and compared
/// field-for-field with exact float bits.
/// </summary>
internal sealed class CollisionShadowVerifier
{
private readonly int _sampleEvery;
private readonly string _artifactDirectory;
private readonly Transition _shadowTransition = new();
private long _queries;
private long _samples;
private long _matches;
private long _mismatches;
private long _faults;
private bool _flatPass;
public CollisionShadowVerifier(
int sampleEvery,
string artifactDirectory)
{
if (sampleEvery <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleEvery));
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDirectory);
_sampleEvery = sampleEvery;
_artifactDirectory = Path.GetFullPath(artifactDirectory);
}
internal bool IsFlatPass => _flatPass;
internal CollisionShadowStats Stats => new(
_queries,
_samples,
_matches,
_mismatches,
_faults);
internal bool TrySample(out long sample)
{
if (_flatPass)
{
sample = 0;
return false;
}
long query = ++_queries;
if (query % _sampleEvery != 0)
{
sample = 0;
return false;
}
sample = ++_samples;
return true;
}
internal Transition PrepareShadow(Transition source)
{
_shadowTransition.CopyFrom(source);
return _shadowTransition;
}
internal void BeginFlatPass()
{
if (_flatPass)
throw new InvalidOperationException(
"Collision shadow flat passes cannot overlap.");
_flatPass = true;
}
internal void EndFlatPass()
{
if (!_flatPass)
throw new InvalidOperationException(
"No collision shadow flat pass is active.");
_flatPass = false;
}
internal void RecordBoolean(
long sample,
string kind,
uint sourceId,
bool graph,
bool flat,
string input)
{
if (graph == flat)
{
_matches++;
return;
}
RecordMismatch(
sample,
kind,
sourceId,
"Result",
graph.ToString(),
flat.ToString(),
input);
}
internal void RecordSphere(
long sample,
string kind,
uint sourceId,
FlatCollisionSphere graph,
FlatCollisionSphere flat)
{
if (Exact(graph.Origin, flat.Origin) &&
Exact(graph.Radius, flat.Radius))
{
_matches++;
return;
}
RecordMismatch(
sample,
kind,
sourceId,
"RootBoundingSphere",
Format(graph),
Format(flat),
string.Empty);
}
internal void RecordTransition(
long sample,
string kind,
uint sourceId,
TransitionState graphState,
TransitionState flatState,
Transition graph,
Transition flat,
string input)
{
if (graphState != flatState)
{
RecordMismatch(
sample,
kind,
sourceId,
"TransitionState",
graphState.ToString(),
flatState.ToString(),
input);
return;
}
if (TransitionExactComparer.TryFindDifference(
graph,
flat,
out string path,
out string graphValue,
out string flatValue))
{
RecordMismatch(
sample,
kind,
sourceId,
path,
graphValue,
flatValue,
input);
return;
}
_matches++;
}
internal void RecordFault(
long sample,
string kind,
uint sourceId,
Exception fault,
string input)
{
_faults++;
WriteArtifact(new CollisionShadowMismatchArtifact(
1,
sample,
kind,
$"0x{sourceId:X8}",
"FlatFault",
"graph-authoritative",
$"{fault.GetType().FullName}: {fault.Message}",
input));
}
private void RecordMismatch(
long sample,
string kind,
uint sourceId,
string difference,
string graph,
string flat,
string input)
{
_mismatches++;
WriteArtifact(new CollisionShadowMismatchArtifact(
1,
sample,
kind,
$"0x{sourceId:X8}",
difference,
graph,
flat,
input));
}
private void WriteArtifact(CollisionShadowMismatchArtifact artifact)
{
Directory.CreateDirectory(_artifactDirectory);
string path = Path.Combine(
_artifactDirectory,
FormattableString.Invariant(
$"collision-shadow-{artifact.Sample:D8}.json"));
string json = JsonSerializer.Serialize(
artifact,
CollisionShadowJsonContext.Default
.CollisionShadowMismatchArtifact);
File.WriteAllText(path, json);
}
internal static string FormatInput(
Vector3 center,
float radius) =>
$"center={Format(center)};radius={Bits(radius)}";
internal static string FormatInput(
Vector3 center,
float radius,
bool hasSphere1,
Vector3 sphere1Center,
float sphere1Radius,
Vector3 currentCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld,
Vector3 worldOrigin) =>
$"sphere0={Format(center)}/{Bits(radius)};" +
$"sphere1={(hasSphere1 ? Format(sphere1Center) : "none")}/" +
$"{Bits(sphere1Radius)};current={Format(currentCenter)};" +
$"localZ={Format(localSpaceZ)};scale={Bits(scale)};" +
$"rotation={Format(localToWorld)};origin={Format(worldOrigin)}";
private static string Format(FlatCollisionSphere sphere) =>
$"{Format(sphere.Origin)}/{Bits(sphere.Radius)}";
private static string Format(Vector3 value) =>
$"{Bits(value.X)},{Bits(value.Y)},{Bits(value.Z)}";
private static string Format(Quaternion value) =>
$"{Bits(value.X)},{Bits(value.Y)},{Bits(value.Z)},{Bits(value.W)}";
private static string Bits(float value) =>
$"0x{BitConverter.SingleToInt32Bits(value):X8}";
private static bool Exact(float left, float right) =>
BitConverter.SingleToInt32Bits(left) ==
BitConverter.SingleToInt32Bits(right);
private static bool Exact(Vector3 left, Vector3 right) =>
Exact(left.X, right.X) &&
Exact(left.Y, right.Y) &&
Exact(left.Z, right.Z);
}
internal sealed record CollisionShadowMismatchArtifact(
int SchemaVersion,
long Sample,
string Kind,
string SourceId,
string Difference,
string Graph,
string Flat,
string Input);
[System.Text.Json.Serialization.JsonSerializable(
typeof(CollisionShadowMismatchArtifact))]
internal sealed partial class CollisionShadowJsonContext :
System.Text.Json.Serialization.JsonSerializerContext;
internal static class TransitionExactComparer
{
internal static bool TryFindDifference(
Transition graph,
Transition flat,
out string path,
out string graphValue,
out string flatValue)
{
if (CompareObjectInfo(
graph.ObjectInfo,
flat.ObjectInfo,
out path,
out graphValue,
out flatValue))
{
return true;
}
if (CompareSpherePath(
graph.SpherePath,
flat.SpherePath,
out path,
out graphValue,
out flatValue))
{
return true;
}
return CompareCollisionInfo(
graph.CollisionInfo,
flat.CollisionInfo,
out path,
out graphValue,
out flatValue);
}
private static bool CompareObjectInfo(
ObjectInfo a,
ObjectInfo b,
out string path,
out string av,
out string bv)
{
if (Different("ObjectInfo.State", a.State, b.State, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepUpHeight", a.StepUpHeight, b.StepUpHeight, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepDownHeight", a.StepDownHeight, b.StepDownHeight, out path, out av, out bv)) return true;
if (Different("ObjectInfo.Ethereal", a.Ethereal, b.Ethereal, out path, out av, out bv)) return true;
if (Different("ObjectInfo.StepDown", a.StepDown, b.StepDown, out path, out av, out bv)) return true;
if (Different("ObjectInfo.Scale", a.Scale, b.Scale, out path, out av, out bv)) return true;
if (Different("ObjectInfo.MoverPhysicsState", a.MoverPhysicsState, b.MoverPhysicsState, out path, out av, out bv)) return true;
if (Different("ObjectInfo.TargetId", a.TargetId, b.TargetId, out path, out av, out bv)) return true;
if (Different("ObjectInfo.SelfEntityId", a.SelfEntityId, b.SelfEntityId, out path, out av, out bv)) return true;
if (Different("ObjectInfo.MoverHasGravity", a.MoverHasGravity, b.MoverHasGravity, out path, out av, out bv)) return true;
if (Different("ObjectInfo.VelocityKilled", a.VelocityKilled, b.VelocityKilled, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool CompareCollisionInfo(
CollisionInfo a,
CollisionInfo b,
out string path,
out string av,
out string bv)
{
if (Different("CollisionInfo.ContactPlaneValid", a.ContactPlaneValid, b.ContactPlaneValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlane", a.ContactPlane, b.ContactPlane, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneCellId", a.ContactPlaneCellId, b.ContactPlaneCellId, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneIsWater", a.ContactPlaneIsWater, b.ContactPlaneIsWater, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneValid", a.LastKnownContactPlaneValid, b.LastKnownContactPlaneValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlane", a.LastKnownContactPlane, b.LastKnownContactPlane, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneCellId", a.LastKnownContactPlaneCellId, b.LastKnownContactPlaneCellId, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastKnownContactPlaneIsWater", a.LastKnownContactPlaneIsWater, b.LastKnownContactPlaneIsWater, out path, out av, out bv)) return true;
if (Different("CollisionInfo.SlidingNormalValid", a.SlidingNormalValid, b.SlidingNormalValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.SlidingNormal", a.SlidingNormal, b.SlidingNormal, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollisionNormalValid", a.CollisionNormalValid, b.CollisionNormalValid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollisionNormal", a.CollisionNormal, b.CollisionNormal, out path, out av, out bv)) return true;
if (Different("CollisionInfo.CollidedWithEnvironment", a.CollidedWithEnvironment, b.CollidedWithEnvironment, out path, out av, out bv)) return true;
if (Different("CollisionInfo.FramesStationaryFall", a.FramesStationaryFall, b.FramesStationaryFall, out path, out av, out bv)) return true;
if (Different("CollisionInfo.AdjustOffset", a.AdjustOffset, b.AdjustOffset, out path, out av, out bv)) return true;
if (Different("CollisionInfo.LastCollidedObjectGuid", a.LastCollidedObjectGuid, b.LastCollidedObjectGuid, out path, out av, out bv)) return true;
if (Different("CollisionInfo.ContactPlaneWriteCount", a.ContactPlaneWriteCount, b.ContactPlaneWriteCount, out path, out av, out bv)) return true;
if (DifferentList("CollisionInfo.CollideObjectGuids", a.CollideObjectGuids, b.CollideObjectGuids, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool CompareSpherePath(
SpherePath a,
SpherePath b,
out string path,
out string av,
out string bv)
{
if (Different("SpherePath.NumSphere", a.NumSphere, b.NumSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.LocalSphere", a.LocalSphere, b.LocalSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.GlobalSphere", a.GlobalSphere, b.GlobalSphere, out path, out av, out bv)) return true;
if (DifferentSpheres("SpherePath.GlobalCurrCenter", a.GlobalCurrCenter, b.GlobalCurrCenter, out path, out av, out bv)) return true;
if (Different("SpherePath.BeginPos", a.BeginPos, b.BeginPos, out path, out av, out bv)) return true;
if (Different("SpherePath.EndPos", a.EndPos, b.EndPos, out path, out av, out bv)) return true;
if (Different("SpherePath.CurPos", a.CurPos, b.CurPos, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckPos", a.CheckPos, b.CheckPos, out path, out av, out bv)) return true;
if (Different("SpherePath.BeginOrientation", a.BeginOrientation, b.BeginOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.EndOrientation", a.EndOrientation, b.EndOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CurOrientation", a.CurOrientation, b.CurOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckOrientation", a.CheckOrientation, b.CheckOrientation, out path, out av, out bv)) return true;
if (Different("SpherePath.CurCellId", a.CurCellId, b.CurCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckCellId", a.CheckCellId, b.CheckCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.CarriedBlockOrigin", a.CarriedBlockOrigin, b.CarriedBlockOrigin, out path, out av, out bv)) return true;
if (Different("SpherePath.GlobalOffset", a.GlobalOffset, b.GlobalOffset, out path, out av, out bv)) return true;
if (Different("SpherePath.StepUp", a.StepUp, b.StepUp, out path, out av, out bv)) return true;
if (Different("SpherePath.StepUpNormal", a.StepUpNormal, b.StepUpNormal, out path, out av, out bv)) return true;
if (Different("SpherePath.Collide", a.Collide, b.Collide, out path, out av, out bv)) return true;
if (Different("SpherePath.StepDown", a.StepDown, b.StepDown, out path, out av, out bv)) return true;
if (Different("SpherePath.StepDownAmt", a.StepDownAmt, b.StepDownAmt, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkInterp", a.WalkInterp, b.WalkInterp, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableValid", a.WalkableValid, b.WalkableValid, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkablePlane", a.WalkablePlane, b.WalkablePlane, out path, out av, out bv)) return true;
if (DifferentVectors("SpherePath.WalkableVertices", a.WalkableVertices, b.WalkableVertices, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableUp", a.WalkableUp, b.WalkableUp, out path, out av, out bv)) return true;
if (Different("SpherePath.WalkableAllowance", a.WalkableAllowance, b.WalkableAllowance, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkableValid", a.LastWalkableValid, b.LastWalkableValid, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkablePlane", a.LastWalkablePlane, b.LastWalkablePlane, out path, out av, out bv)) return true;
if (DifferentVectors("SpherePath.LastWalkableVertices", a.LastWalkableVertices, b.LastWalkableVertices, out path, out av, out bv)) return true;
if (Different("SpherePath.LastWalkableUp", a.LastWalkableUp, b.LastWalkableUp, out path, out av, out bv)) return true;
if (Different("SpherePath.BackupCheckPos", a.BackupCheckPos, b.BackupCheckPos, out path, out av, out bv)) return true;
if (Different("SpherePath.BackupCheckCellId", a.BackupCheckCellId, b.BackupCheckCellId, out path, out av, out bv)) return true;
if (Different("SpherePath.NegPolyHit", a.NegPolyHit, b.NegPolyHit, out path, out av, out bv)) return true;
if (Different("SpherePath.NegStepUp", a.NegStepUp, b.NegStepUp, out path, out av, out bv)) return true;
if (Different("SpherePath.NegCollisionNormal", a.NegCollisionNormal, b.NegCollisionNormal, out path, out av, out bv)) return true;
if (Different("SpherePath.CheckWalkable", a.CheckWalkable, b.CheckWalkable, out path, out av, out bv)) return true;
if (Different("SpherePath.InsertType", a.InsertType, b.InsertType, out path, out av, out bv)) return true;
if (Different("SpherePath.PlacementAllowsSliding", a.PlacementAllowsSliding, b.PlacementAllowsSliding, out path, out av, out bv)) return true;
if (Different("SpherePath.ObstructionEthereal", a.ObstructionEthereal, b.ObstructionEthereal, out path, out av, out bv)) return true;
if (Different("SpherePath.BldgCheck", a.BldgCheck, b.BldgCheck, out path, out av, out bv)) return true;
if (Different("SpherePath.HitsInteriorCell", a.HitsInteriorCell, b.HitsInteriorCell, out path, out av, out bv)) return true;
if (DifferentList("SpherePath.CellCandidates", a.CellCandidates.OrderedIds, b.CellCandidates.OrderedIds, out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool DifferentSpheres(
string pathPrefix,
Sphere[] a,
Sphere[] b,
out string path,
out string av,
out string bv)
{
for (int i = 0; i < a.Length; i++)
{
if (Different($"{pathPrefix}[{i}].Origin", a[i].Origin, b[i].Origin, out path, out av, out bv)) return true;
if (Different($"{pathPrefix}[{i}].Radius", a[i].Radius, b[i].Radius, out path, out av, out bv)) return true;
}
return Equal(out path, out av, out bv);
}
private static bool DifferentVectors(
string pathPrefix,
Vector3[]? a,
Vector3[]? b,
out string path,
out string av,
out string bv)
{
if (a is null || b is null)
{
if (a is null && b is null)
return Equal(out path, out av, out bv);
return Difference(pathPrefix, a is null ? "null" : "array", b is null ? "null" : "array", out path, out av, out bv);
}
if (a.Length != b.Length)
return Difference(pathPrefix + ".Length", a.Length.ToString(CultureInfo.InvariantCulture), b.Length.ToString(CultureInfo.InvariantCulture), out path, out av, out bv);
for (int i = 0; i < a.Length; i++)
if (Different($"{pathPrefix}[{i}]", a[i], b[i], out path, out av, out bv)) return true;
return Equal(out path, out av, out bv);
}
private static bool DifferentList<T>(
string pathPrefix,
IReadOnlyList<T> a,
IReadOnlyList<T> b,
out string path,
out string av,
out string bv)
{
if (a.Count != b.Count)
return Difference(pathPrefix + ".Count", a.Count.ToString(CultureInfo.InvariantCulture), b.Count.ToString(CultureInfo.InvariantCulture), out path, out av, out bv);
for (int i = 0; i < a.Count; i++)
if (!EqualityComparer<T>.Default.Equals(a[i], b[i]))
return Difference($"{pathPrefix}[{i}]", Format(a[i]), Format(b[i]), out path, out av, out bv);
return Equal(out path, out av, out bv);
}
private static bool Different<T>(
string candidatePath,
T a,
T b,
out string path,
out string av,
out string bv)
{
if (Exact(a, b))
return Equal(out path, out av, out bv);
return Difference(candidatePath, Format(a), Format(b), out path, out av, out bv);
}
private static bool Exact<T>(T a, T b)
{
if (a is float af && b is float bf)
return BitConverter.SingleToInt32Bits(af) == BitConverter.SingleToInt32Bits(bf);
if (a is Vector3 av && b is Vector3 bv)
return Exact(av.X, bv.X) && Exact(av.Y, bv.Y) && Exact(av.Z, bv.Z);
if (a is Quaternion aq && b is Quaternion bq)
return Exact(aq.X, bq.X) && Exact(aq.Y, bq.Y) &&
Exact(aq.Z, bq.Z) && Exact(aq.W, bq.W);
if (a is Plane ap && b is Plane bp)
return Exact(ap.Normal, bp.Normal) && Exact(ap.D, bp.D);
return EqualityComparer<T>.Default.Equals(a, b);
}
private static string Format<T>(T value) => value switch
{
null => "null",
float f => $"0x{BitConverter.SingleToInt32Bits(f):X8}",
Vector3 v => $"{Format(v.X)},{Format(v.Y)},{Format(v.Z)}",
Quaternion q => $"{Format(q.X)},{Format(q.Y)},{Format(q.Z)},{Format(q.W)}",
Plane p => $"{Format(p.Normal)}/{Format(p.D)}",
IFormattable formattable => formattable.ToString(
null,
CultureInfo.InvariantCulture),
_ => value.ToString() ?? string.Empty,
};
private static bool Difference(
string candidatePath,
string a,
string b,
out string path,
out string av,
out string bv)
{
path = candidatePath;
av = a;
bv = b;
return true;
}
private static bool Equal(
out string path,
out string av,
out string bv)
{
path = string.Empty;
av = string.Empty;
bv = string.Empty;
return false;
}
}

View file

@ -23,49 +23,172 @@ internal static class CollisionTraversal
PhysicsDataCache cache, PhysicsDataCache cache,
CellPhysics cell) CellPhysics cell)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"); throw MissingFlat("cell containment");
return flat.RootIndex >= 0; return flat.RootIndex >= 0;
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return cell.CellBSP?.Root is not null; return cell.CellBSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (cell.FlatContainmentBsp ??
throw MissingFlat("cell containment")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = cell.CellBSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasCellContainment",
cell.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasCellContainment",
cell.SourceId,
flatFault,
string.Empty);
}
return graphResult;
} }
internal static bool HasPhysics( internal static bool HasPhysics(
PhysicsDataCache cache, PhysicsDataCache cache,
CellPhysics cell) CellPhysics cell)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics"); throw MissingFlat("cell physics");
return flat.RootIndex >= 0; return flat.RootIndex >= 0;
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return cell.BSP?.Root is not null; return cell.BSP?.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = cell.BSP?.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasCellPhysics",
cell.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasCellPhysics",
cell.SourceId,
flatFault,
string.Empty);
}
return graphResult;
} }
internal static bool HasPhysics( internal static bool HasPhysics(
PhysicsDataCache cache, PhysicsDataCache cache,
GfxObjPhysics gfxObject) GfxObjPhysics gfxObject)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ?? FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics"); throw MissingFlat("GfxObj physics");
return flat.RootIndex >= 0; return flat.RootIndex >= 0;
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return gfxObject.BSP.Root is not null; return gfxObject.BSP.Root is not null;
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = (gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics")).RootIndex >= 0;
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = gfxObject.BSP.Root is not null;
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"HasGfxObjPhysics",
gfxObject.SourceId,
graphResult,
flatResult,
string.Empty);
}
else
{
shadow.RecordFault(
sample,
"HasGfxObjPhysics",
gfxObject.SourceId,
flatFault,
string.Empty);
}
return graphResult;
} }
internal static FlatCollisionSphere RootBoundingSphere( internal static FlatCollisionSphere RootBoundingSphere(
PhysicsDataCache cache, PhysicsDataCache cache,
CellPhysics cell) CellPhysics cell)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics"); throw MissingFlat("cell physics");
@ -73,7 +196,39 @@ internal static class CollisionTraversal
} }
DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere; DatReaderWriter.Types.Sphere sphere = cell.BSP!.Root!.BoundingSphere;
return new FlatCollisionSphere(sphere.Origin, sphere.Radius); var graphResult =
new FlatCollisionSphere(sphere.Origin, sphere.Radius);
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return graphResult;
try
{
shadow.BeginFlatPass();
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics");
FlatCollisionSphere flatResult =
flat.Nodes[flat.RootIndex].BoundingSphere;
shadow.EndFlatPass();
shadow.RecordSphere(
sample,
"RootBoundingSphere",
cell.SourceId,
graphResult,
flatResult);
}
catch (Exception fault)
{
if (shadow.IsFlatPass)
shadow.EndFlatPass();
shadow.RecordFault(
sample,
"RootBoundingSphere",
cell.SourceId,
fault,
string.Empty);
}
return graphResult;
} }
internal static bool PointInsideCell( internal static bool PointInsideCell(
@ -81,14 +236,63 @@ internal static class CollisionTraversal
CellPhysics cell, CellPhysics cell,
Vector3 localPoint) Vector3 localPoint)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"); throw MissingFlat("cell containment");
return FlatBspQuery.PointInsideCellBsp(flat, localPoint); return FlatBspQuery.PointInsideCellBsp(flat, localPoint);
} }
return BSPQuery.PointInsideCellBsp(cell.CellBSP?.Root, localPoint); CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
return BSPQuery.PointInsideCellBsp(
cell.CellBSP?.Root,
localPoint);
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = FlatBspQuery.PointInsideCellBsp(
cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"),
localPoint);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = BSPQuery.PointInsideCellBsp(
cell.CellBSP?.Root,
localPoint);
string input = CollisionShadowVerifier.FormatInput(
localPoint,
0f);
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"PointInsideCell",
cell.SourceId,
graphResult,
flatResult,
input);
}
else
{
shadow.RecordFault(
sample,
"PointInsideCell",
cell.SourceId,
flatFault,
input);
}
return graphResult;
} }
internal static bool SphereIntersectsCell( internal static bool SphereIntersectsCell(
@ -97,7 +301,7 @@ internal static class CollisionTraversal
Vector3 localCenter, Vector3 localCenter,
float radius) float radius)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"); throw MissingFlat("cell containment");
@ -107,12 +311,63 @@ internal static class CollisionTraversal
radius); radius);
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.SphereIntersectsCellBsp( return BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root, cell.CellBSP?.Root,
localCenter, localCenter,
radius); radius);
} }
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = FlatBspQuery.SphereIntersectsCellBsp(
cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"),
localCenter,
radius);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = BSPQuery.SphereIntersectsCellBsp(
cell.CellBSP?.Root,
localCenter,
radius);
string input = CollisionShadowVerifier.FormatInput(
localCenter,
radius);
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"SphereIntersectsCell",
cell.SourceId,
graphResult,
flatResult,
input);
}
else
{
shadow.RecordFault(
sample,
"SphereIntersectsCell",
cell.SourceId,
flatFault,
input);
}
return graphResult;
}
internal static TransitionState FindCollisions( internal static TransitionState FindCollisions(
PhysicsDataCache cache, PhysicsDataCache cache,
CellPhysics cell, CellPhysics cell,
@ -129,7 +384,7 @@ internal static class CollisionTraversal
PhysicsEngine? engine, PhysicsEngine? engine,
Vector3 worldOrigin) Vector3 worldOrigin)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? FlatPhysicsBsp flat = cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics"); throw MissingFlat("cell physics");
@ -149,6 +404,9 @@ internal static class CollisionTraversal
worldOrigin); worldOrigin);
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.FindCollisions( return BSPQuery.FindCollisions(
cell.BSP?.Root, cell.BSP?.Root,
cell.Resolved, cell.Resolved,
@ -166,6 +424,87 @@ internal static class CollisionTraversal
worldOrigin); worldOrigin);
} }
Transition flatTransition = shadow.PrepareShadow(transition);
TransitionState flatState = TransitionState.Invalid;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatState = FlatBspQuery.FindCollisions(
cell.FlatPhysicsBsp ??
throw MissingFlat("cell physics"),
flatTransition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
TransitionState graphState = BSPQuery.FindCollisions(
cell.BSP?.Root,
cell.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
string input = CollisionShadowVerifier.FormatInput(
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
worldOrigin);
if (flatFault is null)
{
shadow.RecordTransition(
sample,
"FindCellCollisions",
cell.SourceId,
graphState,
flatState,
transition,
flatTransition,
input);
}
else
{
shadow.RecordFault(
sample,
"FindCellCollisions",
cell.SourceId,
flatFault,
input);
}
return graphState;
}
internal static TransitionState FindCollisions( internal static TransitionState FindCollisions(
PhysicsDataCache cache, PhysicsDataCache cache,
GfxObjPhysics gfxObject, GfxObjPhysics gfxObject,
@ -182,7 +521,7 @@ internal static class CollisionTraversal
PhysicsEngine? engine, PhysicsEngine? engine,
Vector3 worldOrigin) Vector3 worldOrigin)
{ {
if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) if (UseFlat(cache))
{ {
FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ?? FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics"); throw MissingFlat("GfxObj physics");
@ -202,6 +541,9 @@ internal static class CollisionTraversal
worldOrigin); worldOrigin);
} }
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.FindCollisions( return BSPQuery.FindCollisions(
gfxObject.BSP.Root, gfxObject.BSP.Root,
gfxObject.Resolved, gfxObject.Resolved,
@ -219,6 +561,91 @@ internal static class CollisionTraversal
worldOrigin); worldOrigin);
} }
Transition flatTransition = shadow.PrepareShadow(transition);
TransitionState flatState = TransitionState.Invalid;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatState = FlatBspQuery.FindCollisions(
gfxObject.FlatPhysicsBsp ??
throw MissingFlat("GfxObj physics"),
flatTransition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
TransitionState graphState = BSPQuery.FindCollisions(
gfxObject.BSP.Root,
gfxObject.Resolved,
transition,
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
engine,
worldOrigin);
string input = CollisionShadowVerifier.FormatInput(
localSphereCenter,
localSphereRadius,
hasLocalSphere1,
localSphere1Center,
localSphere1Radius,
localCurrentCenter,
localSpaceZ,
scale,
localToWorld,
worldOrigin);
if (flatFault is null)
{
shadow.RecordTransition(
sample,
"FindGfxObjCollisions",
gfxObject.SourceId,
graphState,
flatState,
transition,
flatTransition,
input);
}
else
{
shadow.RecordFault(
sample,
"FindGfxObjCollisions",
gfxObject.SourceId,
flatFault,
input);
}
return graphState;
}
private static bool UseFlat(PhysicsDataCache cache) =>
cache.CollisionTraversalMode == CollisionTraversalMode.Flat ||
cache.CollisionShadow?.IsFlatPass == true;
private static InvalidOperationException MissingFlat(string kind) => private static InvalidOperationException MissingFlat(string kind) =>
new( new(
$"Flat collision traversal requires a prepared {kind} asset. " + $"Flat collision traversal requires a prepared {kind} asset. " +

View file

@ -11,9 +11,10 @@ namespace AcDream.Core.Physics;
/// <summary> /// <summary>
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup /// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
/// dat objects during streaming. Populated by the streaming worker thread; /// dat objects during streaming and live-object materialization. Prepared
/// read by the physics engine on the game/render thread. ConcurrentDictionary /// payloads are built/read off the hot path; one update-thread publisher
/// makes cross-thread access safe without a global lock. /// installs each graph/flat pair. ConcurrentDictionary keeps diagnostics and
/// tooling reads safe without a global lock.
/// </summary> /// </summary>
public sealed class PhysicsDataCache public sealed class PhysicsDataCache
{ {
@ -21,6 +22,29 @@ public sealed class PhysicsDataCache
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new(); private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new(); private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new(); private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
private readonly ConcurrentDictionary<uint, FlatGfxObjCollisionAsset>
_flatGfxObj = new();
private readonly ConcurrentDictionary<uint, FlatSetupCollision>
_flatSetup = new();
private readonly ConcurrentDictionary<uint, FlatCellStructureCollisionAsset>
_flatCellStruct = new();
private readonly ConcurrentDictionary<uint, FlatEnvCellTopology>
_flatEnvCell = new();
public PhysicsDataCache()
{
if (PhysicsDiagnostics.CollisionShadowSampleEvery > 0)
{
CollisionShadow = new CollisionShadowVerifier(
PhysicsDiagnostics.CollisionShadowSampleEvery,
PhysicsDiagnostics.CollisionShadowArtifactDirectory);
}
}
internal CollisionShadowVerifier? CollisionShadow { get; set; }
public CollisionShadowStats CollisionShadowStats =>
CollisionShadow?.Stats ?? default;
/// <summary> /// <summary>
/// Slice I graph/flat differential selector. Production remains on the /// Slice I graph/flat differential selector. Production remains on the
@ -55,8 +79,14 @@ public sealed class PhysicsDataCache
/// user collide with decorative meshes that don't have a CylSphere or /// user collide with decorative meshes that don't have a CylSphere or
/// per-part BSP. /// per-part BSP.
/// </summary> /// </summary>
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj) public void CacheGfxObj(
uint gfxObjId,
GfxObj gfxObj,
FlatGfxObjCollisionAsset? prepared = null)
{ {
if (prepared is not null)
_flatGfxObj.TryAdd(gfxObjId, prepared);
// Always cache a visual AABB from the mesh vertices — this is cheap // Always cache a visual AABB from the mesh vertices — this is cheap
// and fed by the mesh data that's already loaded. It serves as the // and fed by the mesh data that's already loaded. It serves as the
// fallback collision shape for pure-visual entities. // fallback collision shape for pure-visual entities.
@ -65,18 +95,25 @@ public sealed class PhysicsDataCache
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray); _visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
} }
if (_gfxObj.ContainsKey(gfxObjId)) return; if (_gfxObj.TryGetValue(gfxObjId, out GfxObjPhysics? existing))
{
if (prepared is not null)
existing.FlatPhysicsBsp ??= prepared.PhysicsBsp;
return;
}
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return; if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
if (gfxObj.PhysicsBSP?.Root is null) return; if (gfxObj.PhysicsBSP?.Root is null) return;
if (gfxObj.VertexArray is null) return; if (gfxObj.VertexArray is null) return;
var physics = new GfxObjPhysics var physics = new GfxObjPhysics
{ {
SourceId = gfxObjId,
BSP = gfxObj.PhysicsBSP, BSP = gfxObj.PhysicsBSP,
PhysicsPolygons = gfxObj.PhysicsPolygons, PhysicsPolygons = gfxObj.PhysicsPolygons,
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
Vertices = gfxObj.VertexArray, Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
FlatPhysicsBsp = prepared?.PhysicsBsp,
}; };
_gfxObj[gfxObjId] = physics; _gfxObj[gfxObjId] = physics;
@ -157,17 +194,30 @@ public sealed class PhysicsDataCache
/// Extract and cache the collision shape data from a Setup. /// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached. /// No-ops if the id is already cached.
/// </summary> /// </summary>
public void CacheSetup(uint setupId, Setup setup) public void CacheSetup(
uint setupId,
Setup setup,
FlatSetupCollision? prepared = null)
{ {
if (_setup.ContainsKey(setupId)) return; if (prepared is not null)
_flatSetup.TryAdd(setupId, prepared);
if (_setup.TryGetValue(setupId, out SetupPhysics? existing))
{
if (prepared is not null)
existing.FlatCollision ??= prepared;
return;
}
_setup[setupId] = new SetupPhysics _setup[setupId] = new SetupPhysics
{ {
SourceId = setupId,
CylSpheres = setup.CylSpheres ?? new(), CylSpheres = setup.CylSpheres ?? new(),
Spheres = setup.Spheres ?? new(), Spheres = setup.Spheres ?? new(),
Height = setup.Height, Height = setup.Height,
Radius = setup.Radius, Radius = setup.Radius,
StepUpHeight = setup.StepUpHeight, StepUpHeight = setup.StepUpHeight,
StepDownHeight = setup.StepDownHeight, StepDownHeight = setup.StepDownHeight,
FlatCollision = prepared,
}; };
} }
@ -176,9 +226,19 @@ public sealed class PhysicsDataCache
/// (indoor room geometry). No-ops if the id is already cached or the /// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP. /// CellStruct has no physics BSP.
/// </summary> /// </summary>
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, public void CacheCellStruct(
CellStruct cellStruct, Matrix4x4 worldTransform) uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct,
Matrix4x4 worldTransform,
FlatCellStructureCollisionAsset? preparedStructure = null,
FlatEnvCellTopology? preparedTopology = null)
{ {
if (preparedStructure is not null)
_flatCellStruct.TryAdd(envCellId, preparedStructure);
if (preparedTopology is not null)
_flatEnvCell.TryAdd(envCellId, preparedTopology);
// UCG Stage 1: register in the unified graph for ALL cells — before the // UCG Stage 1: register in the unified graph for ALL cells — before the
// idempotency + null-BSP guards below, so BSP-less cells are still included. // idempotency + null-BSP guards below, so BSP-less cells are still included.
if (!CellGraph.Contains(envCellId)) if (!CellGraph.Contains(envCellId))
@ -216,12 +276,17 @@ public sealed class PhysicsDataCache
var cellPhysics = new CellPhysics var cellPhysics = new CellPhysics
{ {
SourceId = envCellId,
BSP = cellStruct.PhysicsBSP, BSP = cellStruct.PhysicsBSP,
PhysicsPolygons = cellStruct.PhysicsPolygons, PhysicsPolygons = cellStruct.PhysicsPolygons,
Vertices = cellStruct.VertexArray, Vertices = cellStruct.VertexArray,
WorldTransform = worldTransform, WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform, InverseWorldTransform = inverseTransform,
Resolved = resolved, Resolved = resolved,
FlatPhysicsBsp = preparedStructure?.PhysicsBsp,
FlatContainmentBsp = preparedStructure?.ContainmentBsp,
FlatPortalPolygons = preparedStructure?.PortalPolygons,
FlatTopology = preparedTopology,
// ── Phase 2 portal fields ── // ── Phase 2 portal fields ──
CellBSP = cellStruct.CellBSP, CellBSP = cellStruct.CellBSP,
Portals = portals, Portals = portals,
@ -394,9 +459,21 @@ public sealed class PhysicsDataCache
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public FlatGfxObjCollisionAsset? GetFlatGfxObj(uint id) =>
_flatGfxObj.TryGetValue(id, out var value) ? value : null;
public FlatSetupCollision? GetFlatSetup(uint id) =>
_flatSetup.TryGetValue(id, out var value) ? value : null;
public FlatCellStructureCollisionAsset? GetFlatCellStruct(uint id) =>
_flatCellStruct.TryGetValue(id, out var value) ? value : null;
public FlatEnvCellTopology? GetFlatEnvCell(uint id) =>
_flatEnvCell.TryGetValue(id, out var value) ? value : null;
public int GfxObjCount => _gfxObj.Count; public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count; public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count; public int CellStructCount => _cellStruct.Count;
public int FlatGfxObjCount => _flatGfxObj.Count;
public int FlatSetupCount => _flatSetup.Count;
public int FlatCellStructCount => _flatCellStruct.Count;
public int FlatEnvCellCount => _flatEnvCell.Count;
/// <summary> /// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
@ -483,6 +560,12 @@ public sealed class PhysicsDataCache
foreach (var key in _cellStruct.Keys) foreach (var key in _cellStruct.Keys)
if ((key & 0xFFFF0000u) == prefix) if ((key & 0xFFFF0000u) == prefix)
_cellStruct.TryRemove(key, out _); _cellStruct.TryRemove(key, out _);
foreach (var key in _flatCellStruct.Keys)
if ((key & 0xFFFF0000u) == prefix)
_flatCellStruct.TryRemove(key, out _);
foreach (var key in _flatEnvCell.Keys)
if ((key & 0xFFFF0000u) == prefix)
_flatEnvCell.TryRemove(key, out _);
} }
public BuildingPhysics? GetBuilding(uint landcellId) public BuildingPhysics? GetBuilding(uint landcellId)
@ -539,6 +622,7 @@ public sealed class ResolvedPolygon
/// <summary>Cached physics data for a single GfxObj part.</summary> /// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics public sealed class GfxObjPhysics
{ {
public uint SourceId { get; init; }
public required PhysicsBSPTree BSP { get; init; } public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; } public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; } public Sphere? BoundingSphere { get; init; }
@ -551,21 +635,24 @@ public sealed class GfxObjPhysics
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; } public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary> /// <summary>
/// Prepared integer-indexed shadow representation. Optional until Slice I5 /// Prepared integer-indexed representation. Slice I5 guarantees this for
/// dual publication is complete. /// production-published collision objects; graph-only test fixtures may
/// omit it.
/// </summary> /// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; } public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; }
} }
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary> /// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
public sealed class SetupPhysics public sealed class SetupPhysics
{ {
public uint SourceId { get; init; }
public List<CylSphere> CylSpheres { get; init; } = new(); public List<CylSphere> CylSpheres { get; init; } = new();
public List<Sphere> Spheres { get; init; } = new(); public List<Sphere> Spheres { get; init; } = new();
public float Height { get; init; } public float Height { get; init; }
public float Radius { get; init; } public float Radius { get; init; }
public float StepUpHeight { get; init; } public float StepUpHeight { get; init; }
public float StepDownHeight { get; init; } public float StepDownHeight { get; init; }
public FlatSetupCollision? FlatCollision { get; internal set; }
} }
/// <summary> /// <summary>
@ -575,6 +662,7 @@ public sealed class SetupPhysics
/// </summary> /// </summary>
public sealed class CellPhysics public sealed class CellPhysics
{ {
public uint SourceId { get; init; }
/// <summary> /// <summary>
/// The physics BSP tree for this cell. Nullable so that test fixtures /// The physics BSP tree for this cell. Nullable so that test fixtures
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/> /// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
@ -593,7 +681,9 @@ public sealed class CellPhysics
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; } public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
/// <summary> /// <summary>
/// Prepared integer-indexed physics BSP shadow. Optional until Slice I5. /// Prepared integer-indexed physics BSP. Slice I5 guarantees this for
/// production-published collision cells; graph-only test fixtures may
/// omit it.
/// </summary> /// </summary>
public FlatPhysicsBsp? FlatPhysicsBsp { get; init; } public FlatPhysicsBsp? FlatPhysicsBsp { get; init; }
@ -610,11 +700,23 @@ public sealed class CellPhysics
public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; }
/// <summary> /// <summary>
/// Prepared integer-indexed cell-containment BSP shadow. Optional until /// Prepared integer-indexed cell-containment BSP. Slice I5 guarantees this
/// Slice I5. /// for production-published cells; graph-only test fixtures may omit it.
/// </summary> /// </summary>
public FlatCellContainmentBsp? FlatContainmentBsp { get; init; } public FlatCellContainmentBsp? FlatContainmentBsp { get; init; }
/// <summary>
/// Prepared visible-polygon table addressed by
/// <see cref="FlatEnvCellTopology.Portals"/>.
/// </summary>
public FlatPolygonTable? FlatPortalPolygons { get; init; }
/// <summary>
/// Prepared per-EnvCell portal/visibility state. Placement remains on this
/// runtime record's world transforms.
/// </summary>
public FlatEnvCellTopology? FlatTopology { get; init; }
/// <summary> /// <summary>
/// Portal connections to neighbouring cells, in cell-local space. /// Portal connections to neighbouring cells, in cell-local space.
/// Default: empty list. Source: <c>envCell.CellPortals</c>. /// Default: empty list. Source: <c>envCell.CellPortals</c>.

View file

@ -21,6 +21,27 @@ namespace AcDream.Core.Physics;
/// </summary> /// </summary>
public static class PhysicsDiagnostics public static class PhysicsDiagnostics
{ {
/// <summary>
/// Slice I5 graph/flat referee cadence. Zero disables the diagnostic;
/// N samples every Nth collision-traversal entry. The graph path remains
/// authoritative regardless of the comparison result.
/// </summary>
public static int CollisionShadowSampleEvery { get; set; } =
ParsePositiveInt(
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_EVERY"));
/// <summary>
/// Directory for deterministic Slice I5 mismatch artifacts.
/// </summary>
public static string CollisionShadowArtifactDirectory { get; set; } =
Environment.GetEnvironmentVariable(
"ACDREAM_COLLISION_SHADOW_DIR")
?? Path.Combine(
Environment.CurrentDirectory,
".test-out",
"collision-shadow");
/// <summary> /// <summary>
/// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits /// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits
/// one structured <c>[resolve]</c> line per call: input + target + /// one structured <c>[resolve]</c> line per call: input + target +
@ -962,4 +983,14 @@ public static class PhysicsDiagnostics
} }
return "?"; return "?";
} }
private static int ParsePositiveInt(string? value) =>
int.TryParse(
value,
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int parsed)
&& parsed > 0
? parsed
: 0;
} }

View file

@ -182,6 +182,22 @@ public sealed class ObjectInfo
MoverHasGravity = false; MoverHasGravity = false;
VelocityKilled = false; VelocityKilled = false;
} }
internal void CopyFrom(ObjectInfo source)
{
ArgumentNullException.ThrowIfNull(source);
State = source.State;
StepUpHeight = source.StepUpHeight;
StepDownHeight = source.StepDownHeight;
Ethereal = source.Ethereal;
StepDown = source.StepDown;
Scale = source.Scale;
MoverPhysicsState = source.MoverPhysicsState;
TargetId = source.TargetId;
SelfEntityId = source.SelfEntityId;
MoverHasGravity = source.MoverHasGravity;
VelocityKilled = source.VelocityKilled;
}
} }
/// <summary> /// <summary>
@ -398,6 +414,30 @@ public sealed class CollisionInfo
LastCollidedObjectGuid = null; LastCollidedObjectGuid = null;
ContactPlaneWriteCount = 0; ContactPlaneWriteCount = 0;
} }
internal void CopyFrom(CollisionInfo source)
{
ArgumentNullException.ThrowIfNull(source);
_contactPlaneValid = source._contactPlaneValid;
_contactPlane = source._contactPlane;
_contactPlaneCellId = source._contactPlaneCellId;
_contactPlaneIsWater = source._contactPlaneIsWater;
_lastKnownContactPlaneValid = source._lastKnownContactPlaneValid;
_lastKnownContactPlane = source._lastKnownContactPlane;
_lastKnownContactPlaneCellId = source._lastKnownContactPlaneCellId;
_lastKnownContactPlaneIsWater = source._lastKnownContactPlaneIsWater;
SlidingNormalValid = source.SlidingNormalValid;
SlidingNormal = source.SlidingNormal;
CollisionNormalValid = source.CollisionNormalValid;
CollisionNormal = source.CollisionNormal;
CollidedWithEnvironment = source.CollidedWithEnvironment;
FramesStationaryFall = source.FramesStationaryFall;
AdjustOffset = source.AdjustOffset;
CollideObjectGuids.Clear();
CollideObjectGuids.AddRange(source.CollideObjectGuids);
LastCollidedObjectGuid = source.LastCollidedObjectGuid;
ContactPlaneWriteCount = source.ContactPlaneWriteCount;
}
} }
/// <summary> /// <summary>
@ -774,6 +814,65 @@ public sealed class SpherePath
OrderedCellScratch.ResetForReuse(); OrderedCellScratch.ResetForReuse();
} }
internal void CopyFrom(SpherePath source)
{
ArgumentNullException.ThrowIfNull(source);
NumSphere = source.NumSphere;
CopySphereArray(source.LocalSphere, LocalSphere);
CopySphereArray(source.GlobalSphere, GlobalSphere);
CopySphereArray(source.GlobalCurrCenter, GlobalCurrCenter);
BeginPos = source.BeginPos;
EndPos = source.EndPos;
CurPos = source.CurPos;
CheckPos = source.CheckPos;
BeginOrientation = source.BeginOrientation;
EndOrientation = source.EndOrientation;
CurOrientation = source.CurOrientation;
CheckOrientation = source.CheckOrientation;
CurCellId = source.CurCellId;
CheckCellId = source.CheckCellId;
CarriedBlockOrigin = source.CarriedBlockOrigin;
GlobalOffset = source.GlobalOffset;
StepUp = source.StepUp;
StepUpNormal = source.StepUpNormal;
Collide = source.Collide;
StepDown = source.StepDown;
StepDownAmt = source.StepDownAmt;
WalkInterp = source.WalkInterp;
WalkableValid = source.WalkableValid;
WalkablePlane = source.WalkablePlane;
WalkableVertices = source.WalkableVertices is null
? null
: CopyExact(
source.WalkableVertices,
ref _walkableVertexStorage);
WalkableUp = source.WalkableUp;
WalkableAllowance = source.WalkableAllowance;
LastWalkableValid = source.LastWalkableValid;
LastWalkablePlane = source.LastWalkablePlane;
LastWalkableVertices = source.LastWalkableVertices is null
? null
: CopyExact(
source.LastWalkableVertices,
ref _lastWalkableVertexStorage);
LastWalkableUp = source.LastWalkableUp;
BackupCheckPos = source.BackupCheckPos;
BackupCheckCellId = source.BackupCheckCellId;
NegPolyHit = source.NegPolyHit;
NegStepUp = source.NegStepUp;
NegCollisionNormal = source.NegCollisionNormal;
CheckWalkable = source.CheckWalkable;
InsertType = source.InsertType;
PlacementAllowsSliding = source.PlacementAllowsSliding;
ObstructionEthereal = source.ObstructionEthereal;
BldgCheck = source.BldgCheck;
HitsInteriorCell = source.HitsInteriorCell;
CellCandidates.Clear();
foreach (uint id in source.CellCandidates)
CellCandidates.Add(id);
OrderedCellScratch.ResetForReuse();
}
private static Vector3[] CopyExact( private static Vector3[] CopyExact(
ReadOnlySpan<Vector3> source, ReadOnlySpan<Vector3> source,
ref Vector3[]? storage) ref Vector3[]? storage)
@ -800,6 +899,15 @@ public sealed class SpherePath
} }
} }
private static void CopySphereArray(Sphere[] source, Sphere[] destination)
{
for (int i = 0; i < destination.Length; i++)
{
destination[i].Origin = source[i].Origin;
destination[i].Radius = source[i].Radius;
}
}
/// <summary> /// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that /// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and runs the full sphere-slide computation /// caused the step-up attempt and runs the full sphere-slide computation
@ -958,6 +1066,14 @@ public sealed class Transition
CollisionInfo.ResetForReuse(); CollisionInfo.ResetForReuse();
} }
internal void CopyFrom(Transition source)
{
ArgumentNullException.ThrowIfNull(source);
ObjectInfo.CopyFrom(source.ObjectInfo);
SpherePath.CopyFrom(source.SpherePath);
CollisionInfo.CopyFrom(source.CollisionInfo);
}
private static bool DumpEdgeSlideEnabled => private static bool DumpEdgeSlideEnabled =>
Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1";

View file

@ -0,0 +1,29 @@
using System.Collections.Immutable;
using AcDream.Core.Physics;
namespace AcDream.Core.World;
/// <summary>
/// Immutable prepared-collision closure carried by one near-tier landblock
/// build. Keys remain the original DAT source IDs. Cell-structure geometry and
/// per-EnvCell topology stay separate so package aliases retain one reusable
/// geometry identity without conflating cell placement or portal state.
/// </summary>
public sealed record LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset> GfxObjs,
ImmutableDictionary<uint, FlatSetupCollision> Setups,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset> CellStructures,
ImmutableDictionary<uint, FlatEnvCellTopology> EnvCells,
ImmutableArray<uint> GfxObjIds,
ImmutableArray<uint> SetupIds,
ImmutableArray<uint> EnvCellIds)
{
public static readonly LandblockCollisionBuild Empty = new(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty,
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty,
ImmutableArray<uint>.Empty);
}

View file

@ -410,6 +410,12 @@ public sealed class ContentEffectsAudioCompositionTests
(LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject( (LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject(
typeof(LiveEntityCollisionBuilder)); typeof(LiveEntityCollisionBuilder));
public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher(
PhysicsDataCache physicsData,
IPreparedAssetSource preparedAssets) =>
(LiveCollisionAssetPublisher)RuntimeHelpers.GetUninitializedObject(
typeof(LiveCollisionAssetPublisher));
public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new(); public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new();
public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters); public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters);
public ParticleHookSink CreateParticleSink( public ParticleHookSink CreateParticleSink(

View file

@ -381,6 +381,7 @@ public sealed class WorldLifecycleAutomationControllerTests
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0, 0, 0L, 0, 0L, 0L, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L,
0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L,
0, 0, 0, 0, 0, 0, 0, default,
default, default, 0d, 0d, null); default, default, 0d, 0d, null);
private static string NewDirectory() private static string NewDirectory()

View file

@ -0,0 +1,160 @@
using System.Collections.Immutable;
using AcDream.App.Physics;
using AcDream.Content;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Physics;
public sealed class LiveCollisionAssetPublisherTests
{
[Fact]
public void LiveGfxAndSetup_PublishExactPreparedViewOnce()
{
const uint GfxId = 0x0100_0042u;
const uint SetupId = 0x0200_0042u;
var cache = new PhysicsDataCache();
var source = new RecordingSource();
var publisher = new LiveCollisionAssetPublisher(cache, source);
GfxObj gfx = PhysicsGfx();
var setup = new Setup();
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheSetup(SetupId, setup);
publisher.CacheSetup(SetupId, setup);
Assert.Same(source.Gfx, cache.GetFlatGfxObj(GfxId));
Assert.Same(
source.Gfx.PhysicsBsp,
cache.GetGfxObj(GfxId)!.FlatPhysicsBsp);
Assert.Same(source.Setup, cache.GetFlatSetup(SetupId));
Assert.Same(
source.Setup,
cache.GetSetup(SetupId)!.FlatCollision);
Assert.Equal(1, source.GfxReads);
Assert.Equal(1, source.SetupReads);
}
[Fact]
public void VisualOnlyGfx_RemembersPreparedEmptyAssetWithoutGraphRecord()
{
const uint GfxId = 0x0100_0043u;
var cache = new PhysicsDataCache();
var source = new RecordingSource();
var publisher = new LiveCollisionAssetPublisher(cache, source);
var gfx = new GfxObj();
publisher.CacheGfxObj(GfxId, gfx);
publisher.CacheGfxObj(GfxId, gfx);
Assert.Null(cache.GetGfxObj(GfxId));
Assert.Same(source.Gfx, cache.GetFlatGfxObj(GfxId));
Assert.Equal(1, source.GfxReads);
}
[Fact]
public void MissingPreparedAsset_RejectsGraphOnlyLivePublication()
{
var cache = new PhysicsDataCache();
var source = new RecordingSource
{
GfxResult =
PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
.Missing,
};
var publisher = new LiveCollisionAssetPublisher(cache, source);
InvalidDataException fault = Assert.Throws<InvalidDataException>(
() => publisher.CacheGfxObj(0x0100_0044u, PhysicsGfx()));
Assert.Contains("Missing", fault.Message, StringComparison.Ordinal);
Assert.Equal(0, cache.GfxObjCount);
Assert.Equal(0, cache.FlatGfxObjCount);
}
private static GfxObj PhysicsGfx() => new()
{
Flags = GfxObjFlags.HasPhysics,
PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
},
},
VertexArray = new VertexArray(),
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
};
private sealed class RecordingSource : IPreparedCollisionSource
{
private static readonly FlatPhysicsBsp EmptyPhysics = new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
public FlatGfxObjCollisionAsset Gfx { get; } =
new(EmptyPhysics, null, null);
public FlatSetupCollision Setup { get; } = new(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f,
0f,
0f);
public int GfxReads { get; private set; }
public int SetupReads { get; private set; }
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>?
GfxResult { get; init; }
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId) => PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
GfxReads++;
return GfxResult ??
PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
.Loaded(Gfx);
}
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
SetupReads++;
return PreparedCollisionReadResult<FlatSetupCollision>
.Loaded(Setup);
}
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
.Missing;
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
PreparedCollisionReadResult<FlatEnvCellTopology>.Missing;
public PreparedCollisionSourceStats CollisionStats => default;
public void Dispose()
{
}
}
}

View file

@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using System.Numerics; using System.Numerics;
using System.Collections.Immutable;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.Content; using AcDream.Content;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -32,6 +33,7 @@ public sealed class LandblockBuildFactoryTests
Assert.Empty(result.Landblock.Entities); Assert.Empty(result.Landblock.Entities);
Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats); Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats);
Assert.Null(result.EnvCells); Assert.Null(result.EnvCells);
Assert.Null(result.Collisions);
Assert.Equal(HoltburgOrigin, result.Origin); Assert.Equal(HoltburgOrigin, result.Origin);
Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads);
} }
@ -81,6 +83,40 @@ public sealed class LandblockBuildFactoryTests
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats); var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Null(physics.Info); Assert.Null(physics.Info);
Assert.Empty(physics.EnvCells); Assert.Empty(physics.EnvCells);
Assert.NotNull(result.Collisions);
Assert.Empty(result.Collisions.GfxObjs);
Assert.Empty(result.Collisions.Setups);
Assert.Empty(result.Collisions.CellStructures);
Assert.Empty(result.Collisions.EnvCells);
}
[Fact]
public void NearPreparedFaultRejectsWholeGenerationWhileFarNeverReadsCollision()
{
var dat = CreateDat(out RecordingDatProxy proxy);
AddNearFixture(proxy, LandblockId, environmentId: 1);
var source = new TestPreparedCollisionSource(
PreparedAssetReadStatus.Missing);
var factory = new LandblockBuildFactory(
dat,
source,
new object(),
new float[256]);
LandblockBuild? far = factory.Build(
Request(LandblockStreamJobKind.LoadFar));
Assert.NotNull(far);
Assert.Null(far.Collisions);
Assert.Equal(0, source.Reads);
InvalidDataException failure =
Assert.Throws<InvalidDataException>(() =>
factory.Build(Request(LandblockStreamJobKind.LoadNear)));
Assert.Contains(
"complete near-tier generation",
failure.Message,
StringComparison.Ordinal);
Assert.True(source.Reads > 0);
} }
[Fact] [Fact]
@ -103,6 +139,9 @@ public sealed class LandblockBuildFactoryTests
var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats); var physics = Assert.IsType<PhysicsDatBundle>(result.Landblock.PhysicsDats);
Assert.Single(physics.EnvCells); Assert.Single(physics.EnvCells);
Assert.DoesNotContain(missingCell, physics.EnvCells.Keys); Assert.DoesNotContain(missingCell, physics.EnvCells.Keys);
Assert.Single(result.Collisions!.CellStructures);
Assert.Single(result.Collisions.EnvCells);
Assert.DoesNotContain(missingCell, result.Collisions.EnvCells.Keys);
} }
[Fact] [Fact]
@ -215,9 +254,9 @@ public sealed class LandblockBuildFactoryTests
var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray(); var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray();
var factory = new LandblockBuildFactory( var factory = new LandblockBuildFactory(
dat, dat,
TestPreparedCollisionSource.Instance,
new object(), new object(),
heights, heights);
new PhysicsDataCache());
heights[42] = -1f; heights[42] = -1f;
var snapshot = Assert.IsType<float[]>(typeof(LandblockBuildFactory) var snapshot = Assert.IsType<float[]>(typeof(LandblockBuildFactory)
@ -234,9 +273,9 @@ public sealed class LandblockBuildFactoryTests
Assert.Throws<ArgumentException>(() => new LandblockBuildFactory( Assert.Throws<ArgumentException>(() => new LandblockBuildFactory(
dat, dat,
TestPreparedCollisionSource.Instance,
new object(), new object(),
new float[255], new float[255]));
new PhysicsDataCache()));
} }
[Fact] [Fact]
@ -273,6 +312,7 @@ public sealed class LandblockBuildFactoryTests
dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true);
Assert.DoesNotContain(dependencyTypes, dependency => Assert.DoesNotContain(dependencyTypes, dependency =>
dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true); dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true);
Assert.DoesNotContain(typeof(PhysicsDataCache), dependencyTypes);
} }
[Fact] [Fact]
@ -288,11 +328,15 @@ public sealed class LandblockBuildFactoryTests
float[]? heights = region?.LandDefs.LandHeightTable; float[]? heights = region?.LandDefs.LandHeightTable;
Assert.NotNull(heights); Assert.NotNull(heights);
Assert.True(heights.Length >= 256); Assert.True(heights.Length >= 256);
string? pakPath = ResolvePreparedPackagePath(datDirectory);
if (pakPath is null)
throw SkipException.ForSkip("Installed acdream.pak is required.");
using var prepared = new PakPreparedAssetSource(pakPath, bounded);
var factory = new LandblockBuildFactory( var factory = new LandblockBuildFactory(
bounded, bounded,
prepared,
new object(), new object(),
heights, heights);
new PhysicsDataCache());
LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4); LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4);
LandblockBuild? first = factory.Build(request); LandblockBuild? first = factory.Build(request);
@ -302,6 +346,7 @@ public sealed class LandblockBuildFactoryTests
Assert.NotNull(second); Assert.NotNull(second);
Assert.Equal(HoltburgOrigin, first.Origin); Assert.Equal(HoltburgOrigin, first.Origin);
Assert.NotNull(first.EnvCells); Assert.NotNull(first.EnvCells);
Assert.NotNull(first.Collisions);
Assert.NotEmpty(first.Landblock.Entities); Assert.NotEmpty(first.Landblock.Entities);
AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities); AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities);
Assert.Equal( Assert.Equal(
@ -335,6 +380,12 @@ public sealed class LandblockBuildFactoryTests
PhysicsDatBundle physics = Assert.IsType<PhysicsDatBundle>( PhysicsDatBundle physics = Assert.IsType<PhysicsDatBundle>(
first.Landblock.PhysicsDats); first.Landblock.PhysicsDats);
Assert.NotNull(physics.Info); Assert.NotNull(physics.Info);
Assert.Equal(physics.EnvCells.Keys.Order(), first.Collisions.EnvCells.Keys.Order());
Assert.Equal(
physics.EnvCells.Keys.Order(),
first.Collisions.CellStructures.Keys.Order());
Assert.Equal(physics.Setups.Keys.Order(), first.Collisions.Setups.Keys.Order());
Assert.Equal(physics.GfxObjs.Keys.Order(), first.Collisions.GfxObjs.Keys.Order());
foreach (WorldEntity entity in first.Landblock.Entities) foreach (WorldEntity entity in first.Landblock.Entities)
{ {
if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
@ -345,7 +396,11 @@ public sealed class LandblockBuildFactoryTests
} }
private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) => private static LandblockBuildFactory Factory(IDatReaderWriter dat, object gate) =>
new(dat, gate, new float[256], new PhysicsDataCache()); new(
dat,
TestPreparedCollisionSource.Instance,
gate,
new float[256]);
private static LandblockBuildRequest Request( private static LandblockBuildRequest Request(
LandblockStreamJobKind kind, LandblockStreamJobKind kind,
@ -444,6 +499,26 @@ public sealed class LandblockBuildFactoryTests
return Directory.Exists(documents) ? documents : null; return Directory.Exists(documents) ? documents : null;
} }
private static string? ResolvePreparedPackagePath(string datDirectory)
{
string? configured =
System.Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
return configured;
string besideDats = Path.Combine(datDirectory, "acdream.pak");
if (File.Exists(besideDats))
return besideDats;
string documents = Path.Combine(
System.Environment.GetFolderPath(
System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call",
"acdream.pak");
return File.Exists(documents) ? documents : null;
}
private class RecordingDatProxy : DispatchProxy private class RecordingDatProxy : DispatchProxy
{ {
private readonly Dictionary<(Type Type, uint Id), object> _objects = new(); private readonly Dictionary<(Type Type, uint Id), object> _objects = new();
@ -506,4 +581,97 @@ public sealed class LandblockBuildFactoryTests
return null; return null;
} }
} }
private sealed class TestPreparedCollisionSource :
IPreparedCollisionSource
{
private static readonly FlatPhysicsBsp EmptyPhysics = new(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty);
private static readonly FlatCellStructureCollisionAsset EmptyCell =
new(
EmptyPhysics,
new FlatCellContainmentBsp(
-1,
ImmutableArray<FlatCellBspNode>.Empty),
FlatPolygonTable.Empty);
private static readonly FlatEnvCellTopology EmptyTopology = new(
ImmutableArray<FlatEnvCellPortal>.Empty,
ImmutableArray<uint>.Empty,
seenOutside: false);
private readonly PreparedAssetReadStatus _status;
internal TestPreparedCollisionSource(
PreparedAssetReadStatus status =
PreparedAssetReadStatus.Loaded)
{
_status = status;
}
internal static TestPreparedCollisionSource Instance { get; } = new();
internal int Reads { get; private set; }
public PreparedAssetPresence ProbeCollision(
AcDream.Content.Pak.PakAssetType type,
uint sourceFileId) =>
PreparedAssetPresence.Available;
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(
new FlatGfxObjCollisionAsset(
EmptyPhysics,
null,
null));
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(
new FlatSetupCollision(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
0f,
0f,
0f,
0f));
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(EmptyCell);
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default) =>
Result(EmptyTopology);
public PreparedCollisionSourceStats CollisionStats => default;
public void Dispose()
{
}
private PreparedCollisionReadResult<T> Result<T>(T value)
where T : class
{
Reads++;
return _status switch
{
PreparedAssetReadStatus.Loaded =>
PreparedCollisionReadResult<T>.Loaded(value),
PreparedAssetReadStatus.Missing =>
PreparedCollisionReadResult<T>.Missing,
_ => PreparedCollisionReadResult<T>.Corrupt,
};
}
}
} }

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using AcDream.App.Streaming; using AcDream.App.Streaming;
@ -94,6 +95,254 @@ public sealed class LandblockPhysicsPublisherTests
Assert.Equal(0x01000077u, building.ModelId); Assert.Equal(0x01000077u, building.ModelId);
} }
[Fact]
public void DualPublication_InstallsExactPreparedClosureAndReleasesCellOwner()
{
const uint envCellId = 0xA9B40100u;
const uint gfxObjId = 0x01000077u;
var fixture = Fixture();
PhysicsDatBundle baseBundle =
CellPortalAndBuildingBundle(envCellId);
DatReaderWriter.DBObjs.Environment environment =
Assert.Single(baseBundle.Environments.Values);
CellStruct cellStruct = Assert.Single(environment.Cells.Values);
cellStruct.PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
},
};
cellStruct.CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
LeafIndex = 1,
},
};
EnvCell envCell = baseBundle.EnvCells[envCellId];
var setup = new Setup();
setup.CylSpheres.Add(new CylSphere
{
Origin = new Vector3(0f, 0f, 0.6f),
Radius = 0.4f,
Height = 1.2f,
});
GfxObj gfx = PhysicsGfx();
var bundle = new PhysicsDatBundle(
baseBundle.Info,
baseBundle.EnvCells,
baseBundle.Environments,
new Dictionary<uint, Setup> { [SetupId] = setup },
new Dictionary<uint, GfxObj> { [gfxObjId] = gfx });
FlatGfxObjCollisionAsset flatGfx =
FlatCollisionAssetBuilder.FlattenGfxObj(gfx);
FlatSetupCollision flatSetup =
FlatCollisionAssetBuilder.FlattenSetup(setup);
FlatCellStructureCollisionAsset flatCell =
FlatCollisionAssetBuilder.FlattenCellStructure(cellStruct);
FlatEnvCellTopology flatTopology =
FlatCollisionAssetBuilder.FlattenEnvCellTopology(
envCellId,
envCell,
flatCell.PortalPolygons);
var collisions = new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty
.Add(gfxObjId, flatGfx),
ImmutableDictionary<uint, FlatSetupCollision>.Empty
.Add(SetupId, flatSetup),
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty
.Add(envCellId, flatCell),
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty
.Add(envCellId, flatTopology),
[gfxObjId],
[SetupId],
[envCellId]);
WorldEntity entity = new()
{
Id = 0x80A9B401u,
SourceGfxObjOrSetupId = SetupId,
Position = new Vector3(12f, 12f, 0f),
Rotation = Quaternion.Identity,
MeshRefs = [new MeshRef(gfxObjId, Matrix4x4.Identity)],
};
Publish(
fixture.Publisher,
Build(
FirstLandblock,
[entity],
bundle,
collisions: collisions));
Assert.Same(flatGfx, fixture.Cache.GetFlatGfxObj(gfxObjId));
Assert.Same(
flatGfx.PhysicsBsp,
fixture.Cache.GetGfxObj(gfxObjId)!.FlatPhysicsBsp);
Assert.Same(flatSetup, fixture.Cache.GetFlatSetup(SetupId));
Assert.Same(
flatSetup,
fixture.Cache.GetSetup(SetupId)!.FlatCollision);
Assert.Same(flatCell, fixture.Cache.GetFlatCellStruct(envCellId));
Assert.Same(flatTopology, fixture.Cache.GetFlatEnvCell(envCellId));
Assert.Same(
flatCell.PhysicsBsp,
fixture.Cache.GetCellStruct(envCellId)!.FlatPhysicsBsp);
fixture.Publisher.RemoveLandblock(FirstLandblock);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
}
[Fact]
public void DualPublication_DemotionRehydrateRevisitAndRemovalOwnFlatCellsExactlyOnce()
{
const uint firstCellId = 0xA9B40100u;
const uint adjacentCellId = 0xAAB40100u;
var fixture = Fixture();
PhysicsDatBundle firstBundle =
CellPortalAndBuildingBundle(firstCellId);
PhysicsDatBundle adjacentBundle =
CellPortalAndBuildingBundle(adjacentCellId);
LandblockCollisionBuild firstCollision =
FlatCellClosure(firstBundle, firstCellId);
LandblockCollisionBuild adjacentCollision =
FlatCellClosure(adjacentBundle, adjacentCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: firstCollision));
Publish(
fixture.Publisher,
Build(
AdjacentLandblock,
bundle: adjacentBundle,
collisions: adjacentCollision));
Assert.Same(
firstCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Same(
adjacentCollision.CellStructures[adjacentCellId],
fixture.Cache.GetFlatCellStruct(adjacentCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
fixture.Publisher.DemoteToTerrain(FirstLandblock);
Assert.Null(fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Null(fixture.Cache.GetFlatEnvCell(firstCellId));
Assert.Same(
adjacentCollision.CellStructures[adjacentCellId],
fixture.Cache.GetFlatCellStruct(adjacentCellId));
Assert.Equal(1, fixture.Cache.FlatCellStructCount);
Assert.Equal(1, fixture.Cache.FlatEnvCellCount);
LandblockCollisionBuild rehydratedCollision =
FlatCellClosure(firstBundle, firstCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: rehydratedCollision));
Assert.Same(
rehydratedCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
LandblockCollisionBuild revisitCollision =
FlatCellClosure(firstBundle, firstCellId);
Publish(
fixture.Publisher,
Build(
FirstLandblock,
bundle: firstBundle,
collisions: revisitCollision));
Assert.Same(
revisitCollision.CellStructures[firstCellId],
fixture.Cache.GetFlatCellStruct(firstCellId));
Assert.Equal(2, fixture.Cache.FlatCellStructCount);
Assert.Equal(2, fixture.Cache.FlatEnvCellCount);
fixture.Publisher.RemoveLandblock(FirstLandblock);
fixture.Publisher.RemoveLandblock(AdjacentLandblock);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.LandblockCount);
}
[Fact]
public void DualPublication_CancelledPreparedReceiptPublishesNeitherView()
{
const uint envCellId = 0xA9B40100u;
var fixture = Fixture();
PhysicsDatBundle bundle =
CellPortalAndBuildingBundle(envCellId);
CellStruct sourceCell =
bundle.Environments.Values.Single().Cells.Values.Single();
sourceCell.PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
},
};
LandblockCollisionBuild collision =
FlatCellClosure(bundle, envCellId);
LandblockPhysicsPublication cancelled =
fixture.Publisher.PreparePublication(
RenderReceipt(Build(
FirstLandblock,
bundle: bundle,
collisions: collision)));
Assert.Equal(0, fixture.Engine.LandblockCount);
Assert.Equal(0, fixture.Cache.CellStructCount);
Assert.Equal(0, fixture.Cache.FlatCellStructCount);
Assert.Equal(0, fixture.Cache.FlatEnvCellCount);
Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount);
LandblockPhysicsPublication accepted =
fixture.Publisher.PreparePublication(
RenderReceipt(Build(
FirstLandblock,
bundle: bundle,
collisions: collision)));
fixture.Publisher.BeginPublication(accepted);
fixture.Publisher.CompletePublication(accepted);
Assert.Same(
collision.CellStructures[envCellId],
fixture.Cache.GetFlatCellStruct(envCellId));
Assert.Equal(1, fixture.Cache.CellStructCount);
Assert.Equal(1, fixture.Cache.FlatCellStructCount);
Assert.Equal(1, fixture.Cache.FlatEnvCellCount);
}
[Fact] [Fact]
public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback() public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback()
{ {
@ -592,6 +841,25 @@ public sealed class LandblockPhysicsPublisherTests
Resolved = new Dictionary<ushort, ResolvedPolygon>(), Resolved = new Dictionary<ushort, ResolvedPolygon>(),
}; };
private static GfxObj PhysicsGfx() => new()
{
Flags = DatReaderWriter.Enums.GfxObjFlags.HasPhysics,
PhysicsBSP = new PhysicsBSPTree
{
Root = new PhysicsBSPNode
{
Type = DatReaderWriter.Enums.BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 1.25f,
},
},
},
PhysicsPolygons = new Dictionary<ushort, Polygon>(),
VertexArray = new VertexArray(),
};
private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId) private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId)
{ {
var cellStruct = new CellStruct var cellStruct = new CellStruct
@ -658,6 +926,33 @@ public sealed class LandblockPhysicsPublisherTests
new Dictionary<uint, GfxObj>()); new Dictionary<uint, GfxObj>());
} }
private static LandblockCollisionBuild FlatCellClosure(
PhysicsDatBundle bundle,
uint envCellId)
{
EnvCell envCell = bundle.EnvCells[envCellId];
DatReaderWriter.DBObjs.Environment environment =
bundle.Environments.Values.Single();
CellStruct cellStruct = environment.Cells[envCell.CellStructure];
FlatCellStructureCollisionAsset flatCell =
FlatCollisionAssetBuilder.FlattenCellStructure(cellStruct);
FlatEnvCellTopology topology =
FlatCollisionAssetBuilder.FlattenEnvCellTopology(
envCellId,
envCell,
flatCell.PortalPolygons);
return new LandblockCollisionBuild(
ImmutableDictionary<uint, FlatGfxObjCollisionAsset>.Empty,
ImmutableDictionary<uint, FlatSetupCollision>.Empty,
ImmutableDictionary<uint, FlatCellStructureCollisionAsset>.Empty
.Add(envCellId, flatCell),
ImmutableDictionary<uint, FlatEnvCellTopology>.Empty
.Add(envCellId, topology),
[],
[],
[envCellId]);
}
private static WorldEntity CylinderEntity( private static WorldEntity CylinderEntity(
uint id, uint id,
Vector3 position, Vector3 position,
@ -674,7 +969,8 @@ public sealed class LandblockPhysicsPublisherTests
uint landblockId, uint landblockId,
IReadOnlyList<WorldEntity>? entities = null, IReadOnlyList<WorldEntity>? entities = null,
PhysicsDatBundle? bundle = null, PhysicsDatBundle? bundle = null,
LandblockBuildOrigin? origin = null) LandblockBuildOrigin? origin = null,
LandblockCollisionBuild? collisions = null)
{ {
var landblockInfo = new LandBlockInfo(); var landblockInfo = new LandBlockInfo();
PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle( PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle(
@ -689,7 +985,8 @@ public sealed class LandblockPhysicsPublisherTests
FlatHeightmap(), FlatHeightmap(),
entities ?? Array.Empty<WorldEntity>(), entities ?? Array.Empty<WorldEntity>(),
physics), physics),
Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4)); Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4),
Collisions: collisions);
} }
private static LandBlock FlatHeightmap() => new() private static LandBlock FlatHeightmap() => new()

View file

@ -0,0 +1,294 @@
using System.Collections.Immutable;
using System.Numerics;
using System.Text.Json;
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics;
public sealed class CollisionShadowVerifierTests
{
[Fact]
public void MatchingDualView_RefereesEveryTraversalWithoutChangingGraph()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics cell = MatchingCell(0xA9B4_0100u);
cache.RegisterCellStructForTest(cell.SourceId, cell);
var transition = SeedTransition();
Assert.True(CollisionTraversal.HasCellContainment(cache, cell));
Assert.True(CollisionTraversal.HasPhysics(cache, cell));
FlatCollisionSphere root =
CollisionTraversal.RootBoundingSphere(cache, cell);
Assert.Equal(10f, root.Radius);
Assert.True(CollisionTraversal.PointInsideCell(
cache,
cell,
Vector3.Zero));
Assert.True(CollisionTraversal.SphereIntersectsCell(
cache,
cell,
Vector3.Zero,
0.48f));
TransitionState state = CollisionTraversal.FindCollisions(
cache,
cell,
transition,
Vector3.Zero,
0.48f,
false,
Vector3.Zero,
0f,
Vector3.Zero,
Vector3.UnitZ,
1f,
Quaternion.Identity,
engine: null,
worldOrigin: Vector3.Zero);
Assert.Equal(TransitionState.OK, state);
CollisionShadowStats stats = cache.CollisionShadowStats;
Assert.Equal(6, stats.Queries);
Assert.Equal(6, stats.Samples);
Assert.Equal(6, stats.Matches);
Assert.Equal(0, stats.Mismatches);
Assert.Equal(0, stats.Faults);
Assert.Empty(Directory.EnumerateFiles(directory));
}
[Fact]
public void Mismatch_WritesDeterministicArtifactAndReturnsGraphResult()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics matching = MatchingCell(0xA9B4_0100u);
CellPhysics mismatched = WithFlatPhysics(
matching,
new FlatPhysicsBsp(
-1,
ImmutableArray<FlatPhysicsBspNode>.Empty,
ImmutableArray<int>.Empty,
FlatPolygonTable.Empty));
bool graphResult = CollisionTraversal.HasPhysics(cache, mismatched);
Assert.True(graphResult);
CollisionShadowStats stats = cache.CollisionShadowStats;
Assert.Equal(1, stats.Mismatches);
Assert.Equal(0, stats.Faults);
string artifact = Assert.Single(Directory.EnumerateFiles(directory));
Assert.Equal(
"collision-shadow-00000001.json",
Path.GetFileName(artifact));
using JsonDocument json = JsonDocument.Parse(File.ReadAllText(artifact));
JsonElement root = json.RootElement;
Assert.Equal(1, root.GetProperty("SchemaVersion").GetInt32());
Assert.Equal("HasCellPhysics", root.GetProperty("Kind").GetString());
Assert.Equal("0xA9B40100", root.GetProperty("SourceId").GetString());
Assert.Equal("Result", root.GetProperty("Difference").GetString());
Assert.Equal("True", root.GetProperty("Graph").GetString());
Assert.Equal("False", root.GetProperty("Flat").GetString());
}
[Fact]
public void MissingFlat_IsRecordedAsFaultAndGraphRemainsAuthoritative()
{
string directory = NewArtifactDirectory();
PhysicsDataCache cache = CacheWithShadow(directory);
CellPhysics cell = WithoutFlatContainment(
MatchingCell(0xA9B4_0100u));
bool graphResult = CollisionTraversal.PointInsideCell(
cache,
cell,
Vector3.Zero);
Assert.True(graphResult);
Assert.Equal(1, cache.CollisionShadowStats.Faults);
string artifact = Assert.Single(Directory.EnumerateFiles(directory));
Assert.Contains(
"FlatFault",
File.ReadAllText(artifact),
StringComparison.Ordinal);
}
[Fact]
public void TransitionCopyAndComparer_CoverCompleteLogicalState()
{
Transition source = SeedTransition();
source.ObjectInfo.State =
ObjectInfoState.Contact | ObjectInfoState.IsPlayer;
source.ObjectInfo.TargetId = 0x5000_0001u;
source.ObjectInfo.VelocityKilled = true;
source.SpherePath.NumSphere = 2;
source.SpherePath.LocalSphere[1].Origin = Vector3.UnitZ;
source.SpherePath.LocalSphere[1].Radius = 0.48f;
source.SpherePath.CarriedBlockOrigin = new Vector3(192f, -192f, 0f);
source.SpherePath.SetWalkable(
new Plane(Vector3.UnitZ, -1f),
[
Vector3.Zero,
Vector3.UnitX,
Vector3.UnitY,
],
Vector3.UnitZ);
source.SpherePath.CellCandidates.Add(0xA9B4_0001u);
source.SpherePath.CellCandidates.Add(0xA9B4_0100u);
source.CollisionInfo.SetContactPlane(
new Plane(Vector3.UnitZ, -1f),
0xA9B4_0100u);
source.CollisionInfo.SetSlidingNormal(Vector3.UnitX);
source.CollisionInfo.CollideObjectGuids.Add(0x8000_0001u);
source.CollisionInfo.LastCollidedObjectGuid = 0x8000_0001u;
var clone = new Transition();
clone.CopyFrom(source);
Assert.False(TransitionExactComparer.TryFindDifference(
source,
clone,
out _,
out _,
out _));
clone.SpherePath.WalkableVertices![1].X =
BitConverter.Int32BitsToSingle(
BitConverter.SingleToInt32Bits(
clone.SpherePath.WalkableVertices[1].X) + 1);
Assert.True(TransitionExactComparer.TryFindDifference(
source,
clone,
out string path,
out _,
out _));
Assert.Equal("SpherePath.WalkableVertices[1]", path);
}
[Fact]
public void DisabledGraphTraversal_AllocatesZeroBytes()
{
var cache = new PhysicsDataCache
{
CollisionShadow = null,
};
CellPhysics cell = MatchingCell(0xA9B4_0100u);
for (int i = 0; i < 100; i++)
_ = CollisionTraversal.HasPhysics(cache, cell);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
_ = CollisionTraversal.HasPhysics(cache, cell);
long allocated =
GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
}
private static PhysicsDataCache CacheWithShadow(string directory)
{
var cache = new PhysicsDataCache();
cache.CollisionShadow = new CollisionShadowVerifier(1, directory);
return cache;
}
private static CellPhysics MatchingCell(uint sourceId)
{
var physicsRoot = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = Vector3.Zero,
Radius = 10f,
},
};
var containmentRoot = new CellBSPNode
{
Type = BSPNodeType.Leaf,
LeafIndex = 1,
};
var resolved = new Dictionary<ushort, ResolvedPolygon>();
return new CellPhysics
{
SourceId = sourceId,
BSP = new PhysicsBSPTree { Root = physicsRoot },
Resolved = resolved,
CellBSP = new CellBSPTree { Root = containmentRoot },
FlatPhysicsBsp =
FlatCollisionAssetBuilder.FlattenPhysicsBsp(
physicsRoot,
resolved),
FlatContainmentBsp =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(
containmentRoot),
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
};
}
private static Transition SeedTransition()
{
var transition = new Transition();
transition.SpherePath.InitPath(
Vector3.Zero,
Vector3.Zero,
0xA9B4_0100u,
0.48f);
return transition;
}
private static CellPhysics WithFlatPhysics(
CellPhysics source,
FlatPhysicsBsp flat) => new()
{
SourceId = source.SourceId,
BSP = source.BSP,
PhysicsPolygons = source.PhysicsPolygons,
Vertices = source.Vertices,
WorldTransform = source.WorldTransform,
InverseWorldTransform = source.InverseWorldTransform,
Resolved = source.Resolved,
FlatPhysicsBsp = flat,
CellBSP = source.CellBSP,
FlatContainmentBsp = source.FlatContainmentBsp,
FlatPortalPolygons = source.FlatPortalPolygons,
FlatTopology = source.FlatTopology,
Portals = source.Portals,
PortalPolygons = source.PortalPolygons,
VisibleCellIds = source.VisibleCellIds,
SeenOutside = source.SeenOutside,
};
private static CellPhysics WithoutFlatContainment(
CellPhysics source) => new()
{
SourceId = source.SourceId,
BSP = source.BSP,
PhysicsPolygons = source.PhysicsPolygons,
Vertices = source.Vertices,
WorldTransform = source.WorldTransform,
InverseWorldTransform = source.InverseWorldTransform,
Resolved = source.Resolved,
FlatPhysicsBsp = source.FlatPhysicsBsp,
CellBSP = source.CellBSP,
FlatContainmentBsp = null,
FlatPortalPolygons = source.FlatPortalPolygons,
FlatTopology = source.FlatTopology,
Portals = source.Portals,
PortalPolygons = source.PortalPolygons,
VisibleCellIds = source.VisibleCellIds,
SeenOutside = source.SeenOutside,
};
private static string NewArtifactDirectory()
{
string directory = Path.Combine(
Path.GetTempPath(),
"acdream-collision-shadow-tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}

View file

@ -8,7 +8,8 @@ param(
[switch]$DenseTown, [switch]$DenseTown,
[switch]$CaptureContention, [switch]$CaptureContention,
[switch]$SkipRuntimeCounters, [switch]$SkipRuntimeCounters,
[int]$LoginTimeoutSeconds = 90 [int]$LoginTimeoutSeconds = 90,
[int]$CollisionShadowEvery = 0
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@ -67,6 +68,7 @@ $markerLog = "$prefix.markers.log"
$sampleCsv = "$prefix.samples.csv" $sampleCsv = "$prefix.samples.csv"
$reportJson = "$prefix.report.json" $reportJson = "$prefix.report.json"
$artifactDir = "$prefix.artifacts" $artifactDir = "$prefix.artifacts"
$collisionShadowDir = Join-Path $artifactDir 'collision-shadow'
$checkpointTimeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' $checkpointTimeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl'
$frameHistory = Join-Path $artifactDir 'frame-history.csv' $frameHistory = Join-Path $artifactDir 'frame-history.csv'
$frameSummary = Join-Path $artifactDir 'frame-history-summary.json' $frameSummary = Join-Path $artifactDir 'frame-history-summary.json'
@ -440,6 +442,26 @@ function Add-CanonicalCheckpointFailures([Diagnostics.Process]$Client) {
} }
} }
if ($CollisionShadowEvery -gt 0) {
$shadow = $checkpoint.resources.PSObject.Properties['collisionShadow']
if ($null -eq $shadow) {
$failures.Add("canonical checkpoint '$expectedName' is missing resources.collisionShadow")
}
else {
foreach ($field in @('mismatches', 'faults')) {
$property = $shadow.Value.PSObject.Properties[$field]
if ($null -eq $property) {
$failures.Add(
"canonical checkpoint '$expectedName' is missing resources.collisionShadow.$field")
}
elseif ([long]$property.Value -ne 0) {
$failures.Add(
"canonical checkpoint '$expectedName' has resources.collisionShadow.$field=$($property.Value), expected zero")
}
}
}
}
$streamingWork = $streamingWork =
$checkpoint.resources.PSObject.Properties['streamingWork'] $checkpoint.resources.PSObject.Properties['streamingWork']
if ($null -eq $streamingWork) { if ($null -eq $streamingWork) {
@ -753,6 +775,10 @@ $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir
$env:ACDREAM_DUMP_MOVE_TRUTH = '1' $env:ACDREAM_DUMP_MOVE_TRUTH = '1'
$env:ACDREAM_NO_AUDIO = $null $env:ACDREAM_NO_AUDIO = $null
$env:ACDREAM_WB_DIAG = $null $env:ACDREAM_WB_DIAG = $null
$env:ACDREAM_COLLISION_SHADOW_EVERY =
if ($CollisionShadowEvery -gt 0) { "$CollisionShadowEvery" } else { $null }
$env:ACDREAM_COLLISION_SHADOW_DIR =
if ($CollisionShadowEvery -gt 0) { $collisionShadowDir } else { $null }
# Machine-readable disclosure of the exact launch configuration (2026-07-24 # Machine-readable disclosure of the exact launch configuration (2026-07-24
# measurement-tooling review — the prior audit could only see # measurement-tooling review — the prior audit could only see
@ -781,6 +807,7 @@ $envDisclosure = [pscustomobject][ordered]@{
DenseTown = [bool]$DenseTown DenseTown = [bool]$DenseTown
CaptureContention = [bool]$CaptureContention CaptureContention = [bool]$CaptureContention
RuntimeCounters = -not [bool]$SkipRuntimeCounters RuntimeCounters = -not [bool]$SkipRuntimeCounters
CollisionShadowEvery = $CollisionShadowEvery
Route = $routeFileName Route = $routeFileName
EnvironmentVariables = @($acdreamEnvVars) EnvironmentVariables = @($acdreamEnvVars)
} }
@ -897,6 +924,25 @@ try {
30 30
$canonicalCheckpoints = @(Read-CanonicalCheckpoints) $canonicalCheckpoints = @(Read-CanonicalCheckpoints)
Add-CanonicalCheckpointFailures $process Add-CanonicalCheckpointFailures $process
if ($CollisionShadowEvery -gt 0 -and $canonicalCheckpoints.Count -gt 0) {
$lastShadow =
$canonicalCheckpoints[-1].resources.PSObject.Properties['collisionShadow']
$shadowSamples = if ($null -ne $lastShadow) {
$lastShadow.Value.PSObject.Properties['samples']
}
if ($null -eq $shadowSamples -or [long]$shadowSamples.Value -le 0) {
$failures.Add('collision shadow did not sample any query')
}
$mismatchArtifacts = @(
Get-ChildItem -LiteralPath $collisionShadowDir `
-Filter 'collision-shadow-*.json' `
-File `
-ErrorAction SilentlyContinue)
if ($mismatchArtifacts.Count -ne 0) {
$failures.Add(
"collision shadow emitted $($mismatchArtifacts.Count) mismatch artifact(s)")
}
}
if (-not $DenseTown) { if (-not $DenseTown) {
Add-RelativeGateFailures Add-RelativeGateFailures
} }
@ -980,6 +1026,7 @@ finally {
BinaryCommit = $binaryCommit BinaryCommit = $binaryCommit
BinaryMatchesSource = $binaryMatchesSource BinaryMatchesSource = $binaryMatchesSource
SessionName = $env:SESSIONNAME SessionName = $env:SESSIONNAME
CollisionShadowEvery = $CollisionShadowEvery
ExitCode = $exitCode ExitCode = $exitCode
GracefulExit = $gracefulExit GracefulExit = $gracefulExit
Failures = @($failures) Failures = @($failures)

View file

@ -5,7 +5,8 @@ param(
[string]$Password = $env:ACDREAM_TEST_PASS, [string]$Password = $env:ACDREAM_TEST_PASS,
[string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt', [string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt',
[switch]$SkipBuild, [switch]$SkipBuild,
[int]$SessionTimeoutSeconds = 420 [int]$SessionTimeoutSeconds = 420,
[int]$CollisionShadowEvery = 0
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@ -178,6 +179,24 @@ function Validate-Checkpoint([string]$SessionLabel, [object]$Checkpoint) {
if ($null -eq $resources.lastFrameProfile) { if ($null -eq $resources.lastFrameProfile) {
$failures.Add("${SessionLabel}/${name}: no frame-profiler sample was available") $failures.Add("${SessionLabel}/${name}: no frame-profiler sample was available")
} }
if ($CollisionShadowEvery -gt 0) {
$shadow = $resources.PSObject.Properties['collisionShadow']
if ($null -eq $shadow) {
$failures.Add("${SessionLabel}/${name}: collision shadow counters are missing")
}
else {
foreach ($field in @('mismatches', 'faults')) {
$property = $shadow.Value.PSObject.Properties[$field]
if ($null -eq $property) {
$failures.Add("${SessionLabel}/${name}: collisionShadow.$field is missing")
}
elseif ([long]$property.Value -ne 0) {
$failures.Add(
"${SessionLabel}/${name}: collisionShadow.$field=$($property.Value), expected zero")
}
}
}
}
} }
function Invoke-Session( function Invoke-Session(
@ -193,6 +212,7 @@ function Invoke-Session(
$stdout = Join-Path $sessionDir 'stdout.log' $stdout = Join-Path $sessionDir 'stdout.log'
$stderr = Join-Path $sessionDir 'stderr.log' $stderr = Join-Path $sessionDir 'stderr.log'
$timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' $timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl'
$collisionShadowDir = Join-Path $artifactDir 'collision-shadow'
$client = $null $client = $null
$clientPort = $null $clientPort = $null
$graceful = $false $graceful = $false
@ -215,6 +235,10 @@ function Invoke-Session(
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir
$env:ACDREAM_DUMP_MOVE_TRUTH = $null $env:ACDREAM_DUMP_MOVE_TRUTH = $null
$env:ACDREAM_WB_DIAG = $null $env:ACDREAM_WB_DIAG = $null
$env:ACDREAM_COLLISION_SHADOW_EVERY =
if ($CollisionShadowEvery -gt 0) { "$CollisionShadowEvery" } else { $null }
$env:ACDREAM_COLLISION_SHADOW_DIR =
if ($CollisionShadowEvery -gt 0) { $collisionShadowDir } else { $null }
try { try {
$client = Start-Process -FilePath $exe -WorkingDirectory $Repository ` $client = Start-Process -FilePath $exe -WorkingDirectory $Repository `
@ -248,6 +272,24 @@ function Invoke-Session(
} }
} }
foreach ($checkpoint in $checkpoints) { Validate-Checkpoint $Label $checkpoint } foreach ($checkpoint in $checkpoints) { Validate-Checkpoint $Label $checkpoint }
if ($CollisionShadowEvery -gt 0 -and $checkpoints.Count -gt 0) {
$lastShadow = $checkpoints[-1].resources.PSObject.Properties['collisionShadow']
$samples = if ($null -ne $lastShadow) {
$lastShadow.Value.PSObject.Properties['samples']
}
if ($null -eq $samples -or [long]$samples.Value -le 0) {
$failures.Add("${Label}: collision shadow did not sample any query")
}
$mismatchArtifacts = @(
Get-ChildItem -LiteralPath $collisionShadowDir `
-Filter 'collision-shadow-*.json' `
-File `
-ErrorAction SilentlyContinue)
if ($mismatchArtifacts.Count -ne 0) {
$failures.Add(
"${Label}: collision shadow emitted $($mismatchArtifacts.Count) mismatch artifact(s)")
}
}
foreach ($name in $ExpectedScreenshots) { foreach ($name in $ExpectedScreenshots) {
$png = Join-Path $artifactDir "screenshots\$name.png" $png = Join-Path $artifactDir "screenshots\$name.png"
@ -373,6 +415,7 @@ $report = [pscustomobject][ordered]@{
Commit = (& git -C $Repository rev-parse HEAD).Trim() Commit = (& git -C $Repository rev-parse HEAD).Trim()
SourceStatus = @(& git -C $Repository status --short) SourceStatus = @(& git -C $Repository status --short)
SessionName = $env:SESSIONNAME SessionName = $env:SESSIONNAME
CollisionShadowEvery = $CollisionShadowEvery
VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue |
ForEach-Object { [pscustomobject]@{ ForEach-Object { [pscustomobject]@{
Name = $_.Name Name = $_.Name