From d9446030e68f6d90f85f77364e2a4d2ee31fb525 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 16:38:54 +0200 Subject: [PATCH] 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 --- AGENTS.md | 15 +- CLAUDE.md | 15 +- docs/architecture/worldbuilder-inventory.md | 11 + docs/plans/2026-04-11-roadmap.md | 13 +- docs/plans/2026-05-12-milestones.md | 10 +- .../2026-07-25-modern-runtime-slice-i.md | 16 +- ...26-07-25-slice-i5-dual-collision-shadow.md | 91 +++ .../ContentEffectsAudioComposition.cs | 18 + .../Composition/FrameRootComposition.cs | 3 + .../Composition/SessionPlayerComposition.cs | 12 +- .../WorldLifecycleAutomationController.cs | 9 + .../WorldLifecycleResourceSnapshotSource.cs | 12 + src/AcDream.App/Input/PlayerModeController.cs | 11 +- .../Physics/LiveCollisionAssetPublisher.cs | 68 +++ .../DatLiveEntityProjectionMaterializer.cs | 15 +- src/AcDream.App/Streaming/LandblockBuild.cs | 3 +- .../Streaming/LandblockBuildFactory.cs | 134 ++++- .../Streaming/LandblockPhysicsPublisher.cs | 53 +- .../Physics/CollisionShadowVerifier.cs | 555 ++++++++++++++++++ .../Physics/CollisionTraversal.cs | 459 ++++++++++++++- src/AcDream.Core/Physics/PhysicsDataCache.cs | 132 ++++- .../Physics/PhysicsDiagnostics.cs | 31 + src/AcDream.Core/Physics/TransitionTypes.cs | 116 ++++ .../World/LandblockCollisionBuild.cs | 29 + .../ContentEffectsAudioCompositionTests.cs | 6 + ...WorldLifecycleAutomationControllerTests.cs | 1 + .../LiveCollisionAssetPublisherTests.cs | 160 +++++ .../Streaming/LandblockBuildFactoryTests.cs | 182 +++++- .../LandblockPhysicsPublisherTests.cs | 301 +++++++++- .../Physics/CollisionShadowVerifierTests.cs | 294 ++++++++++ tools/run-connected-r6-soak.ps1 | 49 +- tools/run-connected-world-lifecycle-gate.ps1 | 45 +- 32 files changed, 2777 insertions(+), 92 deletions(-) create mode 100644 docs/research/2026-07-25-slice-i5-dual-collision-shadow.md create mode 100644 src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs create mode 100644 src/AcDream.Core/Physics/CollisionShadowVerifier.cs create mode 100644 src/AcDream.Core/World/LandblockCollisionBuild.cs create mode 100644 tests/AcDream.App.Tests/Physics/LiveCollisionAssetPublisherTests.cs create mode 100644 tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs diff --git a/AGENTS.md b/AGENTS.md index 12a417b5..1a5aa4cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, 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. -Production remains graph-authoritative until the connected shadow and cutover -gates. +Production remains graph-authoritative until the Slice I6 cutover. +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 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 is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or 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-i2-flat-collision-schema.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 behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/CLAUDE.md b/CLAUDE.md index 8cebfd66..865d449c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, 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. -Production remains graph-authoritative until the connected shadow and cutover -gates. +Production remains graph-authoritative until the Slice I6 cutover. +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 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 is `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`; do not revert G3 or 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-i2-flat-collision-schema.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 behavior-preserving `GameWindow` decomposition slices and the automated diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index 95636b56..a57e76eb 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -144,6 +144,17 @@ EnvCell topology remains independently keyed. Production traversal is still the parsed graph oracle until Slice I6. Full-catalog evidence: `docs/research/2026-07-25-slice-i3-prepared-collision-package.md`. +**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 2.1.7 models `CreateBlockingParticleHook` as the common hook header only, while retail inherits the complete `CreateParticleHook` payload. The narrow readers in diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 2f38852b..4eb1f36d 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1661,13 +1661,18 @@ port in any phase — no separate listing here. > installed-DAT comparisons, 1,200 complete resolver frames, and 10,000 warmed > iterations at zero managed bytes with no mismatch. Production remains > 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 > rollback remains > `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. Slice H is complete: > retained UI/frame work, exact bounded light selection, and > pooled/borrowed/direct network I/O pass 8,292 Release tests / 5 skips and the -> connected lifecycle/reconnect gate. Slice I is active at I5 from +> connected lifecycle/reconnect gate. Slice I is active at I6 from > [its detailed plan](2026-07-25-modern-runtime-slice-i.md). **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` | | MP1b | Pak format + `acdream-bake` CLI + mmap `PakReader` + equivalence/full-scale gate | ✅ **SHIPPED 2026-07-24** — 729,888 EnvCell keys → 17,117 unique geometries + 712,771 aliases (42.6×), 751,141 total keys / 38,370 physical blobs, zero failures, 28,192.4 MiB, 81.4 s validated atomic publish; full-DAT gate also corrected a real collision in WB's legacy geometry hash. [Report](../research/2026-07-24-slice-b-full-bake-report.md) | | **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | -| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` reuse | 🟡 Physics half SHIPPED 2026-07-25 — retail-shaped reset-complete transition/query scratch is bit-identical and 0 B/resolve across five profiles; 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) | | MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, user-requested 2× default range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m and defaults to 128m; landscape/entity streaming distance unchanged | | MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever | -| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | 🟡 ACTIVE — Slice I1 zero-allocation transition scratch, 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 | --- diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index c9501067..5fd99914 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 state/float-bit equality; 10,000 warmed flat iterations allocate zero managed bytes. Release build and 8,384 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 I5 under +Slice I5 is complete: immutable near-build collision closures and strict live +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). The historical exact G4 visual rollback remains `git revert ef1d263337997bb030eadb7b8e71d73dc659907a`. diff --git a/docs/plans/2026-07-25-modern-runtime-slice-i.md b/docs/plans/2026-07-25-modern-runtime-slice-i.md index b65bafb4..cb7d4122 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-i.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-i.md @@ -1,8 +1,9 @@ # 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 -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 **Purpose:** remove parsed DAT object graphs and steady collision allocations 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 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 1. Flip canonical `PhysicsDataCache` records and every `BSPQuery` production diff --git a/docs/research/2026-07-25-slice-i5-dual-collision-shadow.md b/docs/research/2026-07-25-slice-i5-dual-collision-shadow.md new file mode 100644 index 00000000..17b5d570 --- /dev/null +++ b/docs/research/2026-07-25-slice-i5-dual-collision-shadow.md @@ -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 ` command must be recorded before correctness/visual +acceptance, after which production graph retention can be removed. diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index 916454b0..ec61de85 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -30,6 +30,7 @@ internal sealed record ContentEffectsAudioResult( MagicCatalog MagicCatalog, IAnimationLoader AnimationLoader, LiveEntityCollisionBuilder CollisionBuilder, + LiveCollisionAssetPublisher CollisionAssets, EmitterDescRegistry EmitterRegistry, ParticleSystem ParticleSystem, ParticleHookSink ParticleSink, @@ -95,6 +96,9 @@ internal interface IContentEffectsAudioCompositionFactory IDatReaderWriter dats, IAnimationLoader animationLoader, bool dumpMotionEnabled); + LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher( + PhysicsDataCache physicsData, + IPreparedAssetSource preparedAssets); EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats); ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters); ParticleHookSink CreateParticleSink( @@ -165,6 +169,15 @@ internal sealed class RetailContentEffectsAudioCompositionFactory animationLoader, 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) => new(dats); @@ -332,6 +345,10 @@ internal sealed class ContentEffectsAudioCompositionPhase : _dependencies.DumpMotionEnabled); _publication.PublishLiveEntityCollisionBuilder(collision); Fault(ContentEffectsAudioCompositionPoint.CollisionBuilderPublished); + LiveCollisionAssetPublisher collisionAssets = + _factory.CreateLiveCollisionAssetPublisher( + _dependencies.PhysicsDataCache, + preparedAssets); EmitterDescRegistry emitters = _factory.CreateEmitterRegistry(dats); _publication.PublishEmitterRegistry(emitters); @@ -403,6 +420,7 @@ internal sealed class ContentEffectsAudioCompositionPhase : magic, animations, collision, + collisionAssets, emitters, particles, particleSink, diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 5bf6f4d8..0a24b161 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -441,6 +441,9 @@ internal sealed class FrameRootCompositionPhase d.FrameProfiler, content.Dats, foundation.Residency, + d.PhysicsEngine.DataCache + ?? throw new InvalidOperationException( + "Lifecycle automation requires the canonical physics cache."), currentRenderSceneOracle, renderSceneShadowComparison, renderFrameProduct); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 716d3bae..ad134d1b 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -12,6 +12,7 @@ using AcDream.App.Settings; using AcDream.App.Streaming; using AcDream.App.Update; using AcDream.App.World; +using AcDream.Content; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Chat; @@ -252,11 +253,16 @@ internal sealed class SessionPlayerCompositionPhase $"(window={2 * farRadius + 1}x{2 * farRadius + 1})"); 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( content.Dats, + preparedCollisions, d.DatLock, world.TerrainBuild.HeightTable, - d.PhysicsDataCache, d.Options.DumpSceneryZ); var streamerLease = scope.Acquire( "landblock streamer", @@ -399,7 +405,7 @@ internal sealed class SessionPlayerCompositionPhase d.Options, content.Dats, live.LiveEntities, - d.PhysicsDataCache, + content.CollisionAssets, content.AnimationLoader, live.EntitySpawnAdapter, world.Foundation.TextureCache, @@ -689,7 +695,7 @@ internal sealed class SessionPlayerCompositionPhase d.MotionBindings, content.Dats, d.DatLock, - d.PhysicsDataCache, + content.CollisionAssets, d.AnimatedEntities, localPlayerAnimation, localPlayerShadow, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs index 3d8e8ba0..31d62d1f 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs @@ -4,6 +4,7 @@ using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Scene; using AcDream.App.Streaming; using AcDream.App.UI.Testing; +using AcDream.Core.Physics; namespace AcDream.App.Diagnostics; @@ -81,6 +82,14 @@ internal sealed record WorldLifecycleResourceSnapshot( long DatObjectCacheHits, long DatObjectCacheMisses, long DatObjectCacheEvictions, + int PhysicsGraphGfxObjs, + int PhysicsGraphSetups, + int PhysicsGraphCells, + int PhysicsFlatGfxObjs, + int PhysicsFlatSetups, + int PhysicsFlatCells, + int PhysicsFlatEnvCells, + CollisionShadowStats CollisionShadow, StreamingWorkDiagnostics StreamingWork, ResidencySnapshot Residency, double Fps, diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index a5740511..98820720 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -6,6 +6,7 @@ using AcDream.App.Rendering.Residency; using AcDream.App.Streaming; using AcDream.App.World; using AcDream.Content; +using AcDream.Core.Physics; using AcDream.Core.Vfx; using DatReaderWriter.Lib.IO; @@ -39,6 +40,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource private readonly FrameProfiler _frameProfiler; private readonly IDatReaderWriter _dats; private readonly ResidencyManager _residency; + private readonly PhysicsDataCache _physics; private readonly ICurrentRenderSceneOracleSnapshotSource? _renderSceneOracle; private readonly IRenderSceneShadowSnapshotSource? @@ -63,6 +65,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource FrameProfiler frameProfiler, IDatReaderWriter dats, ResidencyManager residency, + PhysicsDataCache physics, ICurrentRenderSceneOracleSnapshotSource? renderSceneOracle = null, IRenderSceneShadowSnapshotSource? renderSceneShadow = null, IRenderFrameProductSnapshotSource? renderFrameProduct = null) @@ -92,6 +95,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _residency = residency ?? throw new ArgumentNullException(nameof(residency)); + _physics = physics ?? throw new ArgumentNullException(nameof(physics)); _renderSceneOracle = renderSceneOracle; _renderSceneShadow = renderSceneShadow; _renderFrameProduct = renderFrameProduct; @@ -184,6 +188,14 @@ internal sealed class WorldLifecycleResourceSnapshotSource DatObjectCacheHits: datObjectCacheStats.Hits, DatObjectCacheMisses: datObjectCacheStats.Misses, 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, Residency: residency, Fps: render.Fps, diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index cab9c996..2c1014e7 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -33,7 +33,7 @@ internal sealed class PlayerModeController : private readonly ILiveEntityMotionRuntimeBindings _motionBindings; private readonly IDatReaderWriter _dats; private readonly object _datLock; - private readonly PhysicsDataCache _physicsDataCache; + private readonly LiveCollisionAssetPublisher _collisionAssets; private readonly LiveEntityAnimationRuntimeView _animations; private readonly LocalPlayerAnimationController _animation; private readonly LocalPlayerShadowSynchronizer _shadow; @@ -59,7 +59,7 @@ internal sealed class PlayerModeController : ILiveEntityMotionRuntimeBindings motionBindings, IDatReaderWriter dats, object datLock, - PhysicsDataCache physicsDataCache, + LiveCollisionAssetPublisher collisionAssets, LiveEntityAnimationRuntimeView animations, LocalPlayerAnimationController animation, LocalPlayerShadowSynchronizer shadow, @@ -82,7 +82,8 @@ internal sealed class PlayerModeController : _motionBindings = motionBindings ?? throw new ArgumentNullException(nameof(motionBindings)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _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)); _animation = animation ?? throw new ArgumentNullException(nameof(animation)); _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow)); @@ -536,7 +537,9 @@ internal sealed class PlayerModeController : setup = _dats.Get( playerEntity.SourceGfxObjOrSetupId); if (setup is not null) - _physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, setup); + _collisionAssets.CacheSetup( + playerEntity.SourceGfxObjOrSetupId, + setup); controller.StepUpHeight = setup is { StepUpHeight: > 0f } ? setup.StepUpHeight : 0.4f; diff --git a/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs b/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs new file mode 100644 index 00000000..09e166af --- /dev/null +++ b/src/AcDream.App/Physics/LiveCollisionAssetPublisher.cs @@ -0,0 +1,68 @@ +using AcDream.Content; +using AcDream.Core.Physics; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Physics; + +/// +/// Publishes the graph and prepared collision views for DAT objects discovered +/// through the live-object path. Streamed statics carry their prepared closure +/// on LandblockBuild; live CreateObject/appearance data arrives outside +/// that transaction and therefore uses this matching strict publisher. +/// +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( + PreparedCollisionReadResult 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."); + } +} diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 34bbed82..f1ca7a18 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -30,7 +30,7 @@ internal sealed class DatLiveEntityProjectionMaterializer private readonly RuntimeOptions _options; private readonly IDatReaderWriter _dats; private readonly LiveEntityRuntime _runtime; - private readonly PhysicsDataCache _physicsData; + private readonly LiveCollisionAssetPublisher _collisionAssets; private readonly IAnimationLoader _animationLoader; private readonly EntitySpawnAdapter _spawnAdapter; private readonly TextureCache _textures; @@ -62,7 +62,7 @@ internal sealed class DatLiveEntityProjectionMaterializer RuntimeOptions options, IDatReaderWriter dats, LiveEntityRuntime runtime, - PhysicsDataCache physicsData, + LiveCollisionAssetPublisher collisionAssets, IAnimationLoader animationLoader, EntitySpawnAdapter spawnAdapter, TextureCache textures, @@ -82,7 +82,8 @@ internal sealed class DatLiveEntityProjectionMaterializer _options = options ?? throw new ArgumentNullException(nameof(options)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); _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)); _spawnAdapter = spawnAdapter ?? throw new ArgumentNullException(nameof(spawnAdapter)); _textures = textures ?? throw new ArgumentNullException(nameof(textures)); @@ -172,7 +173,7 @@ internal sealed class DatLiveEntityProjectionMaterializer Setup? setup = _dats.Get(canonicalSpawn.SetupTableId.Value); if (setup is not null) - _physicsData.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); + _collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); if (setup is null) { _missingSetup++; @@ -282,7 +283,7 @@ internal sealed class DatLiveEntityProjectionMaterializer continue; } - _physicsData.CacheGfxObj(part.GfxObjId, gfx); + _collisionAssets.CacheGfxObj(part.GfxObjId, gfx); if (dumpClothing) { var subMeshes = GfxObjMesh.Build(gfx, _dats); @@ -430,7 +431,7 @@ internal sealed class DatLiveEntityProjectionMaterializer } if (slotZeroGfx is not null) - _physicsData.CacheGfxObj(slotZeroId, slotZeroGfx); + _collisionAssets.CacheGfxObj(slotZeroId, slotZeroGfx); return slotZeroId; } @@ -512,7 +513,7 @@ internal sealed class DatLiveEntityProjectionMaterializer } continue; } - _physicsData.CacheGfxObj(parts[partIndex].GfxObjId, gfx); + _collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx); Dictionary? resolved = null; foreach (var surfaceQid in gfx.Surfaces) diff --git a/src/AcDream.App/Streaming/LandblockBuild.cs b/src/AcDream.App/Streaming/LandblockBuild.cs index cc17ff99..4a04929d 100644 --- a/src/AcDream.App/Streaming/LandblockBuild.cs +++ b/src/AcDream.App/Streaming/LandblockBuild.cs @@ -11,7 +11,8 @@ namespace AcDream.App.Streaming; public sealed record LandblockBuild( LoadedLandblock Landblock, EnvCellLandblockBuild? EnvCells = null, - LandblockBuildOrigin Origin = default) + LandblockBuildOrigin Origin = default, + LandblockCollisionBuild? Collisions = null) { public uint LandblockId => Landblock.LandblockId; } diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index ae03882f..dffe9998 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using DatReaderWriter; using AcDream.Content; @@ -17,19 +18,21 @@ namespace AcDream.App.Streaming; public sealed class LandblockBuildFactory { private readonly IDatReaderWriter _dats; + private readonly IPreparedCollisionSource _preparedCollisions; private readonly object _datLock; private readonly float[] _heightTable; - private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache; private readonly bool _dumpSceneryZ; public LandblockBuildFactory( IDatReaderWriter dats, + IPreparedCollisionSource preparedCollisions, object datLock, float[] heightTable, - AcDream.Core.Physics.PhysicsDataCache physicsDataCache, bool dumpSceneryZ = false) { _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _preparedCollisions = preparedCollisions ?? + throw new ArgumentNullException(nameof(preparedCollisions)); _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); ArgumentNullException.ThrowIfNull(heightTable); if (heightTable.Length < 256) @@ -37,7 +40,6 @@ public sealed class LandblockBuildFactory "The retail terrain height table must contain at least 256 entries.", nameof(heightTable)); _heightTable = (float[])heightTable.Clone(); - _physicsDataCache = physicsDataCache ?? throw new ArgumentNullException(nameof(physicsDataCache)); _dumpSceneryZ = dumpSceneryZ; } @@ -73,6 +75,7 @@ public sealed class LandblockBuildFactory // lock during a CreateObject flood) AND lock-HOLD (the intrinsic build // cost). Identical work in both branches; the probe branch only adds the // stopwatch + log. No behavior change when ProbeTeleportEnabled is false. + LandblockBuild? build; if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) { var sw = System.Diagnostics.Stopwatch.StartNew(); @@ -80,17 +83,28 @@ public sealed class LandblockBuildFactory { long waitedMs = sw.ElapsedMilliseconds; sw.Restart(); - var built = BuildLocked(request); + build = BuildLocked(request); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "BUILD", request.LandblockId, $"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( @@ -143,7 +157,6 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(e.SourceGfxObjOrSetupId); if (gfx is not null) { - _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef( @@ -156,13 +169,11 @@ public sealed class LandblockBuildFactory var setup = _dats.Get(e.SourceGfxObjOrSetupId); if (setup is not null) { - _physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value); meshRefs.Add(mr); @@ -293,6 +304,103 @@ public sealed class LandblockBuildFactory return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); } + /// + /// 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. + /// + private AcDream.Core.World.LandblockCollisionBuild + BuildPreparedCollisionClosure( + AcDream.Core.World.LoadedLandblock landblock) + { + AcDream.Core.World.PhysicsDatBundle dats = + landblock.PhysicsDats ?? + AcDream.Core.World.PhysicsDatBundle.Empty; + ImmutableArray gfxObjIds = [.. dats.GfxObjs.Keys.Order()]; + ImmutableArray setupIds = [.. dats.Setups.Keys.Order()]; + ImmutableArray 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( + PreparedCollisionReadResult 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."); + } + /// /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single /// landblock on the worker thread. Pure CPU — no GL calls. @@ -363,7 +471,6 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(spawn.ObjectId); if (gfx is not null) { - _physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); @@ -374,13 +481,11 @@ public sealed class LandblockBuildFactory var setup = _dats.Get(spawn.ObjectId); if (setup is not null) { - _physicsDataCache.CacheSetup(spawn.ObjectId, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); // Compose: part's own transform, then the spawn's scale. var partXf = mr.PartTransform * scaleMat; var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); @@ -687,7 +792,6 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(stab.Id); if (gfx is not null) { - _physicsDataCache.CacheGfxObj(stab.Id, gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); 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)); @@ -702,7 +806,6 @@ public sealed class LandblockBuildFactory var setup = _dats.Get(stab.Id); if (setup is not null) { - _physicsDataCache.CacheSetup(stab.Id, setup); stabLightCount = setup.Lights.Count; var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); 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"); continue; } - _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value); meshRefs.Add(mr); diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index 8330231c..1d21c744 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -46,6 +46,8 @@ public sealed class LandblockPhysicsPublication internal int BuildingCursor { get; set; } internal bool BaseCommitted { get; set; } internal int GfxCursor { get; set; } + internal uint[] SetupObjectIds { get; set; } = Array.Empty(); + internal int SetupCursor { get; set; } internal int PriorStaticCursor { get; set; } internal int StaticCursor { get; set; } internal int BspOwnerCount { get; set; } @@ -199,7 +201,7 @@ public sealed class LandblockPhysicsPublisher build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() ?? Array.Empty(); - return new LandblockPhysicsPublication( + var publication = new LandblockPhysicsPublication( _receiptOwner, build, origin, @@ -207,6 +209,10 @@ public sealed class LandblockPhysicsPublisher buildings, _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( build.Landblock.LandblockId)); + publication.SetupObjectIds = build.Collisions is { } collisions + ? [.. collisions.SetupIds] + : datBundle.Setups.Keys.Order().ToArray(); + return publication; } /// @@ -223,6 +229,14 @@ public sealed class LandblockPhysicsPublisher IReadOnlyList 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) { WorldEntity entity = entities[publication.PreparationCursor]; @@ -389,10 +403,33 @@ public sealed class LandblockPhysicsPublisher long cacheStarted = Stopwatch.GetTimestamp(); uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor]; 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++; _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 < publication.PriorStaticOwnerIds.Length) { @@ -486,11 +523,21 @@ public sealed class LandblockPhysicsPublisher Matrix4x4.CreateFromQuaternion(rotation) * 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( envCellId, envCell, cellStruct, - physicsCellTransform); + physicsCellTransform, + preparedStructure, + preparedTopology); var worldVertices = new Dictionary( cellStruct.VertexArray.Vertices.Count); diff --git a/src/AcDream.Core/Physics/CollisionShadowVerifier.cs b/src/AcDream.Core/Physics/CollisionShadowVerifier.cs new file mode 100644 index 00000000..2cf8eaed --- /dev/null +++ b/src/AcDream.Core/Physics/CollisionShadowVerifier.cs @@ -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); + +/// +/// 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. +/// +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( + string pathPrefix, + IReadOnlyList a, + IReadOnlyList 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.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( + 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 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.Default.Equals(a, b); + } + + private static string Format(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; + } +} diff --git a/src/AcDream.Core/Physics/CollisionTraversal.cs b/src/AcDream.Core/Physics/CollisionTraversal.cs index 85f93815..c6d3d347 100644 --- a/src/AcDream.Core/Physics/CollisionTraversal.cs +++ b/src/AcDream.Core/Physics/CollisionTraversal.cs @@ -23,49 +23,172 @@ internal static class CollisionTraversal PhysicsDataCache cache, CellPhysics cell) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? throw MissingFlat("cell containment"); return flat.RootIndex >= 0; } - return cell.CellBSP?.Root is not null; + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + 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( PhysicsDataCache cache, CellPhysics cell) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? throw MissingFlat("cell physics"); return flat.RootIndex >= 0; } - return cell.BSP?.Root is not null; + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + 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( PhysicsDataCache cache, GfxObjPhysics gfxObject) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ?? throw MissingFlat("GfxObj physics"); return flat.RootIndex >= 0; } - return gfxObject.BSP.Root is not null; + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + 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( PhysicsDataCache cache, CellPhysics cell) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? throw MissingFlat("cell physics"); @@ -73,7 +196,39 @@ internal static class CollisionTraversal } 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( @@ -81,14 +236,63 @@ internal static class CollisionTraversal CellPhysics cell, Vector3 localPoint) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? throw MissingFlat("cell containment"); 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( @@ -97,7 +301,7 @@ internal static class CollisionTraversal Vector3 localCenter, float radius) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatCellContainmentBsp flat = cell.FlatContainmentBsp ?? throw MissingFlat("cell containment"); @@ -107,10 +311,61 @@ internal static class CollisionTraversal radius); } - return BSPQuery.SphereIntersectsCellBsp( + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + { + return BSPQuery.SphereIntersectsCellBsp( + cell.CellBSP?.Root, + localCenter, + 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( @@ -129,7 +384,7 @@ internal static class CollisionTraversal PhysicsEngine? engine, Vector3 worldOrigin) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatPhysicsBsp flat = cell.FlatPhysicsBsp ?? throw MissingFlat("cell physics"); @@ -149,7 +404,58 @@ internal static class CollisionTraversal worldOrigin); } - return BSPQuery.FindCollisions( + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + { + return BSPQuery.FindCollisions( + cell.BSP?.Root, + cell.Resolved, + transition, + localSphereCenter, + localSphereRadius, + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, + localCurrentCenter, + localSpaceZ, + scale, + localToWorld, + engine, + 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, @@ -164,6 +470,39 @@ internal static class CollisionTraversal 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( @@ -182,7 +521,7 @@ internal static class CollisionTraversal PhysicsEngine? engine, Vector3 worldOrigin) { - if (cache.CollisionTraversalMode == CollisionTraversalMode.Flat) + if (UseFlat(cache)) { FlatPhysicsBsp flat = gfxObject.FlatPhysicsBsp ?? throw MissingFlat("GfxObj physics"); @@ -202,7 +541,58 @@ internal static class CollisionTraversal worldOrigin); } - return BSPQuery.FindCollisions( + CollisionShadowVerifier? shadow = cache.CollisionShadow; + if (shadow is null || !shadow.TrySample(out long sample)) + { + return BSPQuery.FindCollisions( + gfxObject.BSP.Root, + gfxObject.Resolved, + transition, + localSphereCenter, + localSphereRadius, + hasLocalSphere1, + localSphere1Center, + localSphere1Radius, + localCurrentCenter, + localSpaceZ, + scale, + localToWorld, + engine, + 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, @@ -217,8 +607,45 @@ internal static class CollisionTraversal 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) => new( $"Flat collision traversal requires a prepared {kind} asset. " + diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 9bc5c854..f1f03ffa 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -11,9 +11,10 @@ namespace AcDream.Core.Physics; /// /// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup -/// dat objects during streaming. Populated by the streaming worker thread; -/// read by the physics engine on the game/render thread. ConcurrentDictionary -/// makes cross-thread access safe without a global lock. +/// dat objects during streaming and live-object materialization. Prepared +/// payloads are built/read off the hot path; one update-thread publisher +/// installs each graph/flat pair. ConcurrentDictionary keeps diagnostics and +/// tooling reads safe without a global lock. /// public sealed class PhysicsDataCache { @@ -21,6 +22,29 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); + private readonly ConcurrentDictionary + _flatGfxObj = new(); + private readonly ConcurrentDictionary + _flatSetup = new(); + private readonly ConcurrentDictionary + _flatCellStruct = new(); + private readonly ConcurrentDictionary + _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; /// /// 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 /// per-part BSP. /// - 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 // and fed by the mesh data that's already loaded. It serves as the // fallback collision shape for pure-visual entities. @@ -65,18 +95,25 @@ public sealed class PhysicsDataCache _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.PhysicsBSP?.Root is null) return; if (gfxObj.VertexArray is null) return; var physics = new GfxObjPhysics { + SourceId = gfxObjId, BSP = gfxObj.PhysicsBSP, PhysicsPolygons = gfxObj.PhysicsPolygons, BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, Vertices = gfxObj.VertexArray, Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray), + FlatPhysicsBsp = prepared?.PhysicsBsp, }; _gfxObj[gfxObjId] = physics; @@ -157,17 +194,30 @@ public sealed class PhysicsDataCache /// Extract and cache the collision shape data from a Setup. /// No-ops if the id is already cached. /// - 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 { + SourceId = setupId, CylSpheres = setup.CylSpheres ?? new(), Spheres = setup.Spheres ?? new(), Height = setup.Height, Radius = setup.Radius, StepUpHeight = setup.StepUpHeight, 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 /// CellStruct has no physics BSP. /// - public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, - CellStruct cellStruct, Matrix4x4 worldTransform) + public void CacheCellStruct( + 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 // idempotency + null-BSP guards below, so BSP-less cells are still included. if (!CellGraph.Contains(envCellId)) @@ -216,12 +276,17 @@ public sealed class PhysicsDataCache var cellPhysics = new CellPhysics { + SourceId = envCellId, BSP = cellStruct.PhysicsBSP, PhysicsPolygons = cellStruct.PhysicsPolygons, Vertices = cellStruct.VertexArray, WorldTransform = worldTransform, InverseWorldTransform = inverseTransform, Resolved = resolved, + FlatPhysicsBsp = preparedStructure?.PhysicsBsp, + FlatContainmentBsp = preparedStructure?.ContainmentBsp, + FlatPortalPolygons = preparedStructure?.PortalPolygons, + FlatTopology = preparedTopology, // ── Phase 2 portal fields ── CellBSP = cellStruct.CellBSP, Portals = portals, @@ -394,9 +459,21 @@ public sealed class PhysicsDataCache 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 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 SetupCount => _setup.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; /// /// 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) if ((key & 0xFFFF0000u) == prefix) _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) @@ -539,6 +622,7 @@ public sealed class ResolvedPolygon /// Cached physics data for a single GfxObj part. public sealed class GfxObjPhysics { + public uint SourceId { get; init; } public required PhysicsBSPTree BSP { get; init; } public required Dictionary PhysicsPolygons { get; init; } public Sphere? BoundingSphere { get; init; } @@ -551,21 +635,24 @@ public sealed class GfxObjPhysics public required Dictionary Resolved { get; init; } /// - /// Prepared integer-indexed shadow representation. Optional until Slice I5 - /// dual publication is complete. + /// Prepared integer-indexed representation. Slice I5 guarantees this for + /// production-published collision objects; graph-only test fixtures may + /// omit it. /// - public FlatPhysicsBsp? FlatPhysicsBsp { get; init; } + public FlatPhysicsBsp? FlatPhysicsBsp { get; internal set; } } /// Cached collision shape data for a Setup (character/creature capsule). public sealed class SetupPhysics { + public uint SourceId { get; init; } public List CylSpheres { get; init; } = new(); public List Spheres { get; init; } = new(); public float Height { get; init; } public float Radius { get; init; } public float StepUpHeight { get; init; } public float StepDownHeight { get; init; } + public FlatSetupCollision? FlatCollision { get; internal set; } } /// @@ -575,6 +662,7 @@ public sealed class SetupPhysics /// public sealed class CellPhysics { + public uint SourceId { get; init; } /// /// The physics BSP tree for this cell. Nullable so that test fixtures /// can construct a from @@ -593,7 +681,9 @@ public sealed class CellPhysics public required Dictionary Resolved { get; init; } /// - /// 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. /// public FlatPhysicsBsp? FlatPhysicsBsp { get; init; } @@ -610,11 +700,23 @@ public sealed class CellPhysics public DatReaderWriter.Types.CellBSPTree? CellBSP { get; init; } /// - /// Prepared integer-indexed cell-containment BSP shadow. Optional until - /// Slice I5. + /// Prepared integer-indexed cell-containment BSP. Slice I5 guarantees this + /// for production-published cells; graph-only test fixtures may omit it. /// public FlatCellContainmentBsp? FlatContainmentBsp { get; init; } + /// + /// Prepared visible-polygon table addressed by + /// . + /// + public FlatPolygonTable? FlatPortalPolygons { get; init; } + + /// + /// Prepared per-EnvCell portal/visibility state. Placement remains on this + /// runtime record's world transforms. + /// + public FlatEnvCellTopology? FlatTopology { get; init; } + /// /// Portal connections to neighbouring cells, in cell-local space. /// Default: empty list. Source: envCell.CellPortals. diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index c8a1ba3f..9331ccd7 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -21,6 +21,27 @@ namespace AcDream.Core.Physics; /// public static class PhysicsDiagnostics { + /// + /// 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. + /// + public static int CollisionShadowSampleEvery { get; set; } = + ParsePositiveInt( + Environment.GetEnvironmentVariable( + "ACDREAM_COLLISION_SHADOW_EVERY")); + + /// + /// Directory for deterministic Slice I5 mismatch artifacts. + /// + public static string CollisionShadowArtifactDirectory { get; set; } = + Environment.GetEnvironmentVariable( + "ACDREAM_COLLISION_SHADOW_DIR") + ?? Path.Combine( + Environment.CurrentDirectory, + ".test-out", + "collision-shadow"); + /// /// When true, emits /// one structured [resolve] line per call: input + target + @@ -962,4 +983,14 @@ public static class PhysicsDiagnostics } 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; } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 46c64322..993dba7e 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -182,6 +182,22 @@ public sealed class ObjectInfo MoverHasGravity = 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; + } } /// @@ -398,6 +414,30 @@ public sealed class CollisionInfo LastCollidedObjectGuid = null; 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; + } } /// @@ -774,6 +814,65 @@ public sealed class SpherePath 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( ReadOnlySpan source, 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; + } + } + /// /// Slide fallback when step-up fails. Clears the contact-plane state that /// caused the step-up attempt and runs the full sphere-slide computation @@ -958,6 +1066,14 @@ public sealed class Transition 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 => Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; diff --git a/src/AcDream.Core/World/LandblockCollisionBuild.cs b/src/AcDream.Core/World/LandblockCollisionBuild.cs new file mode 100644 index 00000000..e6de8f5b --- /dev/null +++ b/src/AcDream.Core/World/LandblockCollisionBuild.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using AcDream.Core.Physics; + +namespace AcDream.Core.World; + +/// +/// 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. +/// +public sealed record LandblockCollisionBuild( + ImmutableDictionary GfxObjs, + ImmutableDictionary Setups, + ImmutableDictionary CellStructures, + ImmutableDictionary EnvCells, + ImmutableArray GfxObjIds, + ImmutableArray SetupIds, + ImmutableArray EnvCellIds) +{ + public static readonly LandblockCollisionBuild Empty = new( + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableDictionary.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); +} diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index cf26d7f7..86287148 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -410,6 +410,12 @@ public sealed class ContentEffectsAudioCompositionTests (LiveEntityCollisionBuilder)RuntimeHelpers.GetUninitializedObject( typeof(LiveEntityCollisionBuilder)); + public LiveCollisionAssetPublisher CreateLiveCollisionAssetPublisher( + PhysicsDataCache physicsData, + IPreparedAssetSource preparedAssets) => + (LiveCollisionAssetPublisher)RuntimeHelpers.GetUninitializedObject( + typeof(LiveCollisionAssetPublisher)); + public EmitterDescRegistry CreateEmitterRegistry(IDatReaderWriter dats) => new(); public ParticleSystem CreateParticleSystem(EmitterDescRegistry emitters) => new(emitters); public ParticleHookSink CreateParticleSink( diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 253d5c25..dd392978 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -381,6 +381,7 @@ public sealed class WorldLifecycleAutomationControllerTests 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, 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); private static string NewDirectory() diff --git a/tests/AcDream.App.Tests/Physics/LiveCollisionAssetPublisherTests.cs b/tests/AcDream.App.Tests/Physics/LiveCollisionAssetPublisherTests.cs new file mode 100644 index 00000000..6132ec43 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveCollisionAssetPublisherTests.cs @@ -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 + .Missing, + }; + var publisher = new LiveCollisionAssetPublisher(cache, source); + + InvalidDataException fault = Assert.Throws( + () => 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(), + }; + + private sealed class RecordingSource : IPreparedCollisionSource + { + private static readonly FlatPhysicsBsp EmptyPhysics = new( + -1, + ImmutableArray.Empty, + ImmutableArray.Empty, + FlatPolygonTable.Empty); + + public FlatGfxObjCollisionAsset Gfx { get; } = + new(EmptyPhysics, null, null); + public FlatSetupCollision Setup { get; } = new( + ImmutableArray.Empty, + ImmutableArray.Empty, + 0f, + 0f, + 0f, + 0f); + public int GfxReads { get; private set; } + public int SetupReads { get; private set; } + + public PreparedCollisionReadResult? + GfxResult { get; init; } + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, + uint sourceFileId) => PreparedAssetPresence.Available; + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + GfxReads++; + return GfxResult ?? + PreparedCollisionReadResult + .Loaded(Gfx); + } + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + SetupReads++; + return PreparedCollisionReadResult + .Loaded(Setup); + } + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult + .Missing; + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Missing; + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs index 62108bd3..81dce483 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Numerics; +using System.Collections.Immutable; using AcDream.App.Streaming; using AcDream.Content; using AcDream.Core.Physics; @@ -32,6 +33,7 @@ public sealed class LandblockBuildFactoryTests Assert.Empty(result.Landblock.Entities); Assert.Same(PhysicsDatBundle.Empty, result.Landblock.PhysicsDats); Assert.Null(result.EnvCells); + Assert.Null(result.Collisions); Assert.Equal(HoltburgOrigin, result.Origin); Assert.Equal([(typeof(LandBlock), LandblockId)], proxy.Reads); } @@ -81,6 +83,40 @@ public sealed class LandblockBuildFactoryTests var physics = Assert.IsType(result.Landblock.PhysicsDats); Assert.Null(physics.Info); 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(() => + factory.Build(Request(LandblockStreamJobKind.LoadNear))); + Assert.Contains( + "complete near-tier generation", + failure.Message, + StringComparison.Ordinal); + Assert.True(source.Reads > 0); } [Fact] @@ -103,6 +139,9 @@ public sealed class LandblockBuildFactoryTests var physics = Assert.IsType(result.Landblock.PhysicsDats); Assert.Single(physics.EnvCells); Assert.DoesNotContain(missingCell, physics.EnvCells.Keys); + Assert.Single(result.Collisions!.CellStructures); + Assert.Single(result.Collisions.EnvCells); + Assert.DoesNotContain(missingCell, result.Collisions.EnvCells.Keys); } [Fact] @@ -215,9 +254,9 @@ public sealed class LandblockBuildFactoryTests var heights = Enumerable.Range(0, 256).Select(value => (float)value).ToArray(); var factory = new LandblockBuildFactory( dat, + TestPreparedCollisionSource.Instance, new object(), - heights, - new PhysicsDataCache()); + heights); heights[42] = -1f; var snapshot = Assert.IsType(typeof(LandblockBuildFactory) @@ -234,9 +273,9 @@ public sealed class LandblockBuildFactoryTests Assert.Throws(() => new LandblockBuildFactory( dat, + TestPreparedCollisionSource.Instance, new object(), - new float[255], - new PhysicsDataCache())); + new float[255])); } [Fact] @@ -273,6 +312,7 @@ public sealed class LandblockBuildFactoryTests dependency.FullName?.Contains("LiveWorldOrigin", StringComparison.Ordinal) == true); Assert.DoesNotContain(dependencyTypes, dependency => dependency.Namespace?.StartsWith("Silk.NET", StringComparison.Ordinal) == true); + Assert.DoesNotContain(typeof(PhysicsDataCache), dependencyTypes); } [Fact] @@ -288,11 +328,15 @@ public sealed class LandblockBuildFactoryTests float[]? heights = region?.LandDefs.LandHeightTable; Assert.NotNull(heights); 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( bounded, + prepared, new object(), - heights, - new PhysicsDataCache()); + heights); LandblockBuildRequest request = Request(LandblockStreamJobKind.LoadNear, generation: 4); LandblockBuild? first = factory.Build(request); @@ -302,6 +346,7 @@ public sealed class LandblockBuildFactoryTests Assert.NotNull(second); Assert.Equal(HoltburgOrigin, first.Origin); Assert.NotNull(first.EnvCells); + Assert.NotNull(first.Collisions); Assert.NotEmpty(first.Landblock.Entities); AssertEntityBuildsEqual(first.Landblock.Entities, second.Landblock.Entities); Assert.Equal( @@ -335,6 +380,12 @@ public sealed class LandblockBuildFactoryTests PhysicsDatBundle physics = Assert.IsType( first.Landblock.PhysicsDats); 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) { if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) @@ -345,7 +396,11 @@ public sealed class LandblockBuildFactoryTests } 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( LandblockStreamJobKind kind, @@ -444,6 +499,26 @@ public sealed class LandblockBuildFactoryTests 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 readonly Dictionary<(Type Type, uint Id), object> _objects = new(); @@ -506,4 +581,97 @@ public sealed class LandblockBuildFactoryTests return null; } } + + private sealed class TestPreparedCollisionSource : + IPreparedCollisionSource + { + private static readonly FlatPhysicsBsp EmptyPhysics = new( + -1, + ImmutableArray.Empty, + ImmutableArray.Empty, + FlatPolygonTable.Empty); + private static readonly FlatCellStructureCollisionAsset EmptyCell = + new( + EmptyPhysics, + new FlatCellContainmentBsp( + -1, + ImmutableArray.Empty), + FlatPolygonTable.Empty); + private static readonly FlatEnvCellTopology EmptyTopology = new( + ImmutableArray.Empty, + ImmutableArray.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 + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + Result( + new FlatGfxObjCollisionAsset( + EmptyPhysics, + null, + null)); + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + Result( + new FlatSetupCollision( + ImmutableArray.Empty, + ImmutableArray.Empty, + 0f, + 0f, + 0f, + 0f)); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + Result(EmptyCell); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + Result(EmptyTopology); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() + { + } + + private PreparedCollisionReadResult Result(T value) + where T : class + { + Reads++; + return _status switch + { + PreparedAssetReadStatus.Loaded => + PreparedCollisionReadResult.Loaded(value), + PreparedAssetReadStatus.Missing => + PreparedCollisionReadResult.Missing, + _ => PreparedCollisionReadResult.Corrupt, + }; + } + } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs index 6e7b5101..32154632 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPhysicsPublisherTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Numerics; using System.Reflection; using AcDream.App.Streaming; @@ -94,6 +95,254 @@ public sealed class LandblockPhysicsPublisherTests 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 { [SetupId] = setup }, + new Dictionary { [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.Empty + .Add(gfxObjId, flatGfx), + ImmutableDictionary.Empty + .Add(SetupId, flatSetup), + ImmutableDictionary.Empty + .Add(envCellId, flatCell), + ImmutableDictionary.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] public void CompletePublication_PhysicsBspSuppressesSetupCylinderFallback() { @@ -592,6 +841,25 @@ public sealed class LandblockPhysicsPublisherTests Resolved = new Dictionary(), }; + 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(), + VertexArray = new VertexArray(), + }; + private static PhysicsDatBundle CellPortalAndBuildingBundle(uint envCellId) { var cellStruct = new CellStruct @@ -658,6 +926,33 @@ public sealed class LandblockPhysicsPublisherTests new Dictionary()); } + 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.Empty, + ImmutableDictionary.Empty, + ImmutableDictionary.Empty + .Add(envCellId, flatCell), + ImmutableDictionary.Empty + .Add(envCellId, topology), + [], + [], + [envCellId]); + } + private static WorldEntity CylinderEntity( uint id, Vector3 position, @@ -674,7 +969,8 @@ public sealed class LandblockPhysicsPublisherTests uint landblockId, IReadOnlyList? entities = null, PhysicsDatBundle? bundle = null, - LandblockBuildOrigin? origin = null) + LandblockBuildOrigin? origin = null, + LandblockCollisionBuild? collisions = null) { var landblockInfo = new LandBlockInfo(); PhysicsDatBundle physics = bundle ?? new PhysicsDatBundle( @@ -689,7 +985,8 @@ public sealed class LandblockPhysicsPublisherTests FlatHeightmap(), entities ?? Array.Empty(), physics), - Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4)); + Origin: origin ?? new LandblockBuildOrigin(0xA9, 0xB4), + Collisions: collisions); } private static LandBlock FlatHeightmap() => new() diff --git a/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs new file mode 100644 index 00000000..ec8f69a0 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CollisionShadowVerifierTests.cs @@ -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.Empty, + ImmutableArray.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(); + 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; + } +} diff --git a/tools/run-connected-r6-soak.ps1 b/tools/run-connected-r6-soak.ps1 index 98aa595b..d6566a75 100644 --- a/tools/run-connected-r6-soak.ps1 +++ b/tools/run-connected-r6-soak.ps1 @@ -8,7 +8,8 @@ param( [switch]$DenseTown, [switch]$CaptureContention, [switch]$SkipRuntimeCounters, - [int]$LoginTimeoutSeconds = 90 + [int]$LoginTimeoutSeconds = 90, + [int]$CollisionShadowEvery = 0 ) Set-StrictMode -Version Latest @@ -67,6 +68,7 @@ $markerLog = "$prefix.markers.log" $sampleCsv = "$prefix.samples.csv" $reportJson = "$prefix.report.json" $artifactDir = "$prefix.artifacts" +$collisionShadowDir = Join-Path $artifactDir 'collision-shadow' $checkpointTimeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' $frameHistory = Join-Path $artifactDir 'frame-history.csv' $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 = $checkpoint.resources.PSObject.Properties['streamingWork'] if ($null -eq $streamingWork) { @@ -753,6 +775,10 @@ $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir $env:ACDREAM_DUMP_MOVE_TRUTH = '1' $env:ACDREAM_NO_AUDIO = $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 # measurement-tooling review — the prior audit could only see @@ -781,6 +807,7 @@ $envDisclosure = [pscustomobject][ordered]@{ DenseTown = [bool]$DenseTown CaptureContention = [bool]$CaptureContention RuntimeCounters = -not [bool]$SkipRuntimeCounters + CollisionShadowEvery = $CollisionShadowEvery Route = $routeFileName EnvironmentVariables = @($acdreamEnvVars) } @@ -897,6 +924,25 @@ try { 30 $canonicalCheckpoints = @(Read-CanonicalCheckpoints) 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) { Add-RelativeGateFailures } @@ -980,6 +1026,7 @@ finally { BinaryCommit = $binaryCommit BinaryMatchesSource = $binaryMatchesSource SessionName = $env:SESSIONNAME + CollisionShadowEvery = $CollisionShadowEvery ExitCode = $exitCode GracefulExit = $gracefulExit Failures = @($failures) diff --git a/tools/run-connected-world-lifecycle-gate.ps1 b/tools/run-connected-world-lifecycle-gate.ps1 index d03efc08..c0d4735b 100644 --- a/tools/run-connected-world-lifecycle-gate.ps1 +++ b/tools/run-connected-world-lifecycle-gate.ps1 @@ -5,7 +5,8 @@ param( [string]$Password = $env:ACDREAM_TEST_PASS, [string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt', [switch]$SkipBuild, - [int]$SessionTimeoutSeconds = 420 + [int]$SessionTimeoutSeconds = 420, + [int]$CollisionShadowEvery = 0 ) Set-StrictMode -Version Latest @@ -178,6 +179,24 @@ function Validate-Checkpoint([string]$SessionLabel, [object]$Checkpoint) { if ($null -eq $resources.lastFrameProfile) { $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( @@ -193,6 +212,7 @@ function Invoke-Session( $stdout = Join-Path $sessionDir 'stdout.log' $stderr = Join-Path $sessionDir 'stderr.log' $timeline = Join-Path $artifactDir 'world-lifecycle.checkpoints.jsonl' + $collisionShadowDir = Join-Path $artifactDir 'collision-shadow' $client = $null $clientPort = $null $graceful = $false @@ -215,6 +235,10 @@ function Invoke-Session( $env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $artifactDir $env:ACDREAM_DUMP_MOVE_TRUTH = $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 { $client = Start-Process -FilePath $exe -WorkingDirectory $Repository ` @@ -248,6 +272,24 @@ function Invoke-Session( } } 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) { $png = Join-Path $artifactDir "screenshots\$name.png" @@ -373,6 +415,7 @@ $report = [pscustomobject][ordered]@{ Commit = (& git -C $Repository rev-parse HEAD).Trim() SourceStatus = @(& git -C $Repository status --short) SessionName = $env:SESSIONNAME + CollisionShadowEvery = $CollisionShadowEvery VideoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Name = $_.Name