From 98f1ac8934ebde436aa934b3f52a75de589110da Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 19:10:18 +0200 Subject: [PATCH] perf(streaming): cursor publication across frame budgets --- AGENTS.md | 10 +- CLAUDE.md | 10 +- docs/ISSUES.md | 9 +- docs/README.md | 12 +- docs/architecture/acdream-architecture.md | 14 +- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 5 +- docs/plans/2026-05-12-milestones.md | 10 +- .../2026-07-24-modern-runtime-architecture.md | 9 +- ...runtime-slice-e-cost-budgeted-streaming.md | 25 +- .../LivePresentationComposition.cs | 6 +- .../Rendering/Wb/BuildingLoader.cs | 282 +++--- .../Wb/EnvCellLandblockPublication.cs | 45 + .../Rendering/Wb/EnvCellRenderer.cs | 156 ++- .../Streaming/LandblockPhysicsPublisher.cs | 895 ++++++++++-------- .../LandblockPresentationPipeline.cs | 558 +++++++++-- .../Streaming/LandblockRenderPublisher.cs | 131 ++- .../LandblockStaticPresentationPublisher.cs | 181 +++- .../Streaming/StreamingCompletionQueue.cs | 13 +- .../Streaming/StreamingController.cs | 192 ++-- .../Physics/ShadowObjectRegistry.cs | 145 ++- .../Rendering/Wb/BuildingLoaderTests.cs | 50 + ...dblockConcretePresentationPipelineTests.cs | 67 ++ .../LandblockPresentationPipelineTests.cs | 10 +- .../LandblockRenderPublisherTests.cs | 93 ++ .../LandblockRetirementCoordinatorTests.cs | 11 +- .../StreamingControllerReadinessTests.cs | 6 +- .../StreamingFrameControllerTests.cs | 5 +- .../Streaming/StreamingWorkBudgetTests.cs | 14 +- .../Physics/ShadowObjectRegistryTests.cs | 59 ++ .../Streaming/StreamingControllerTests.cs | 3 +- .../StreamingControllerTwoTierTests.cs | 10 +- 32 files changed, 2215 insertions(+), 823 deletions(-) create mode 100644 src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs diff --git a/AGENTS.md b/AGENTS.md index a845fb47..92f2f0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,10 +140,12 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic, lifetime, full-suite, and user-visual gates passed. Process allocation fell 41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved 17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified -typed residency policy/accounting and bounded retained caches. Slice E0–E3 now +typed residency policy/accounting and bounded retained caches. Slice E0–E4 now enforce typed completion admission, generation-scoped old-world quiescence, -and metered full-window/recenter retirement; E4 cursor-budgeted publication is -next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G +metered full-window/recenter retirement, and retained cursor-budgeted +render/physics/static/building/EnvCell publication; E5 destination reservation +and reveal-generation joining is next. The physical console is available and +reserved for E6 connected gates. Slices F/G remain behind the explicit ECS decision gate and Slice J behind its separate gameplay-owner gate. Evidence: `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and @@ -168,7 +170,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire truth remain correct. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's lifestone/particle alpha visual gate. Start structural work at `memory/project_gamewindow_decomposition.md` and diff --git a/CLAUDE.md b/CLAUDE.md index 733702c7..f9b121bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,10 +138,12 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic, lifetime, full-suite, and user-visual gates passed. Process allocation fell 41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved 17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified -typed residency policy/accounting and bounded retained caches. Slice E0–E3 now +typed residency policy/accounting and bounded retained caches. Slice E0–E4 now enforce typed completion admission, generation-scoped old-world quiescence, -and metered full-window/recenter retirement; E4 cursor-budgeted publication is -next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G +metered full-window/recenter retirement, and retained cursor-budgeted +render/physics/static/building/EnvCell publication; E5 destination reservation +and reveal-generation joining is next. The physical console is available and +reserved for E6 connected gates. Slices F/G remain behind the explicit ECS decision gate and Slice J behind its separate gameplay-owner gate. Evidence: `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and @@ -166,7 +168,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire truth remain correct. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's lifestone/particle alpha visual gate. Start structural work at `memory/project_gamewindow_decomposition.md` and diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c9f7c2ae..0077767a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -44,11 +44,12 @@ What does NOT go here: smooth and physics, collision, and wire state remain correct. - **Build hygiene:** `#228` records 17 clean-Release test-project warnings; production compilation and all tests pass. -- **Performance side track:** Slices A–D and Slice E0–E3 are complete. Typed +- **Performance side track:** Slices A–D and Slice E0–E4 are complete. Typed completion admission, immediate generation-scoped old-world quiescence, and - budgeted full-window/recenter retirement are live; E4 cursor-budgeted - publication is next. Physical performance/visual evidence is paused while - the desktop is RDP. Evidence: + budgeted full-window/recenter retirement are live. Publication now advances + from retained render/physics/static/building/EnvCell cursors without replay; + E5 destination reservation/reveal joining is next. The physical console is + available and remains reserved for the E6 connected gate. Evidence: [`research/2026-07-24-slice-d-unified-residency-report.md`](research/2026-07-24-slice-d-unified-residency-report.md). The [documentation map](README.md) defines how this tactical ledger relates to diff --git a/docs/README.md b/docs/README.md index 1a2037be..0c7e2e2b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,13 +14,15 @@ banner cannot silently override the current program state. [`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md). The eight-slice `GameWindow` decomposition and its connected visual matrix are complete. -- **Current active engineering program:** Slices A–D and Slice E0–E3 of +- **Current active engineering program:** Slices A–D and Slice E0–E4 of [`plans/2026-07-24-modern-runtime-architecture.md`](plans/2026-07-24-modern-runtime-architecture.md) are complete. Production world-mesh streaming consumes the validated prepared package, typed residency is bounded/accounted, and streaming now - has typed admission plus immediate old-generation quiescence and metered - retirement cursors. E4 cursor-budgeted publication is next. Physical - GPU/FPS gates wait for the physical console because the desktop is RDP. See + has typed admission plus immediate old-generation quiescence, metered + retirement, and retained cursor-budgeted publication across render, physics, + statics, building registries, collision/reflood, and EnvCells. E5 destination + reservation/reveal joining is next. The physical console is available; its + connected performance and visual evidence remains the E6 gate. See the [Slice D residency report](research/2026-07-24-slice-d-unified-residency-report.md). - **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and the final two-client portal-out/materialization observer comparison (`#218`). @@ -32,7 +34,7 @@ banner cannot silently override the current program state. user decision gate; Linux/headless automation (Track LH) remains future work. - **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34, and UN 5 — plus the retained retired TS-37 history note. -- **Latest automated baseline:** Release build succeeds; 8,148 tests pass and +- **Latest automated baseline:** Release build succeeds; 8,152 tests pass and five are intentionally skipped. The capped, uncapped, and dense-Arwic physical prepared-asset routes all completed their portal/input/lifetime assertions and disconnected gracefully. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 9cf54e4f..a859daa3 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -731,8 +731,18 @@ readiness continue. Canonical live records remain retained until physical teardown converges, so quiescence neither reconstructs server identity nor invents Hidden/UnHide transitions. Full-window and shared-origin retirement advance only from `StreamingController.Tick` through stable resident and -per-owner entity/stage cursors on the frame's one meter. Publication remains -transaction-atomic until Slice E4. +per-owner entity/stage cursors on the frame's one meter. + +Accepted publication is likewise retained at its exact priority-queue head. +`LandblockPresentationPipeline` meters render, physics, static, building, and +EnvCell receipts one operation at a time; each owner captures stable ordered +inputs once and retries only its unfinished cursor. Building and EnvCell +replacements are constructed off-side and atomically replace their complete +owner snapshots. The final `GpuWorldState.MutationBatch` is intentionally one +observer-atomic boundary so bucket identity, render pins, activation, and the +outer visibility notice cannot be observed partially. Settings and native +callbacks may defer policy changes, but cannot bypass the frame meter to drain +pending publication. ### World-reveal readiness ownership diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5172fd1f..d4eac512 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -68,7 +68,7 @@ accepted-divergence entries (#96, #49, #50). |---|---|---|---|---|---| | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 48fb59ea..a9bc76f0 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1613,7 +1613,10 @@ port in any phase — no separate listing here. > generation unavailable at the hard reveal edge and freezes its world > simulation/audio while full-window and shared-origin teardown advance through > metered resident, entity, and owner-stage cursors with exact retry receipts. -> E4 cursor-budgets publication next. F/G and J retain their explicit approval +> E4 now retains the exact accepted queue head and cursor-budgets render, +> physics, static, building-registry, EnvCell, collision, and reflood +> publication before one observer-atomic spatial swap. E5 destination +> reservation/reveal joining is next. F/G and J retain their explicit approval > gates. **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 4560adf2..5dc109d9 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -93,12 +93,14 @@ The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only production streaming, and typed unified residency. Its physical capped, uncapped, and dense connected gates pass without a visual-quality change. -Slice E0–E3 now define and enforce typed completion admission through explicit +Slice E0–E4 now define and enforce typed completion admission through explicit stable priority queues, publish an immediate generation-scoped old-world quiesce at login/portal boundaries, and advance full-window/recenter teardown -through exact metered retirement cursors. E4 next makes publication itself -cursor-budgeted. Slices F/G remain behind their explicit ECS approval gate, and -Slice J remains behind its gameplay-owner approval gate. +through exact metered retirement cursors. Render, physics, static, building, +and EnvCell publication now resume from retained exact cursors under the same +frame budget. E5 destination reservation and reveal-generation joining is +next. Slices F/G remain behind their explicit ECS approval gate, and Slice J +remains behind its gameplay-owner approval gate. Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's status hand selected-object availability and existing diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index d35fd05f..c695c213 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,7 +2,7 @@ **Date:** 2026-07-24 -**Status:** Slices A–D complete. Slice E is active (E0–E3 complete). +**Status:** Slices A–D complete. Slice E is active (E0–E4 complete). **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. @@ -728,9 +728,10 @@ checkpoint retained staged or retiring bytes. Evidence: **Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current owner inventory. E1 landed the validated work profile, deterministic meter and -completion charge model, plus lifecycle-artifact shadow diagnostics while -preserving the prior scheduler's execution. E2 is the first enforcement -cutover. +completion charge model. E2 cut execution over to stable priority FIFOs. E3 +added immediate generation quiescence and cursored retirement. E4 now advances +render/physics/static publication from retained exact receipts; E5 destination +reservation and reveal-generation joining is next. **Purpose:** Remove update-thread portal transactions. diff --git a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md index 3a958937..0548cbb9 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md @@ -1,6 +1,6 @@ # Modern Runtime Slice E — Cost-budgeted streaming and retirement -**Status:** active — E0/E1/E2/E3 complete; E4 cursor-budgeted publication next +**Status:** active — E0/E1/E2/E3/E4 complete; E5 destination reservation next **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` **Baseline:** Slice D closeout commit `66690805` **Behavior contract:** no visual-quality or retail-behavior reduction @@ -325,6 +325,29 @@ valid GPU/FPS evidence. - Feed GPU requested-byte and retirement-operation facts from renderer owners. - Prove every failure resumes the exact unfinished operation without replay. +**Complete 2026-07-24.** One accepted completion now remains at the exact queue +head while `LandblockPresentationPipeline` advances retained render, physics, +and static receipts through the frame's single meter. Stable entity/Gfx/building +and prior-owner arrays are captured once. Terrain upload bytes are charged at +the render owner; physics cells/buildings, static collision/light/plugin work, +prior static teardown, shadow reflood, building-registry construction, and +EnvCell shell construction advance one bounded operation at a time. Building +and EnvCell replacements are prepared off-side and become visible only when +complete. Failures retain the unfinished cursor and never replay a committed +prefix. Settings/native callbacks can defer policy changes but cannot drain +publication outside `StreamingController.Tick`. + +The final `GpuWorldState.MutationBatch` remains one deliberate observer-atomic +operation: bucket mutation, render-id ownership, activation, and the outer +visibility notification cannot span frames without exposing a partial spatial +identity. It is time-metered and named for E6 overrun evidence; all iterable +owner work now occurs before it. + +Validation: focused render/building/publication/controller tests passed; the +complete App suite passed 3,669 / 3 skipped; the complete Release solution +passed 8,152 / 5 skipped; Release solution build green. Physical connected +performance and visual evidence remains an E6 gate. + ### E5 — Destination reservation and reveal generation - Replace eager `PriorityRadius` execution with destination reservations. diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6f3cf9cb..ea11f461 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -789,11 +789,11 @@ internal sealed class LivePresentationCompositionPhase foundation.Terrain.RemoveLandblock, d.CellVisibility, worldState, - envCellLease.Resource.CommitLandblock, - build => EnvCellMeshPreparationScheduler.Schedule( + prepareEnvCells: build => EnvCellMeshPreparationScheduler.Schedule( build, foundation.MeshAdapter.MeshManager!), - envCellLease.Resource.RemoveLandblock); + removeEnvCells: envCellLease.Resource.RemoveLandblock, + envCellPublisher: envCellLease.Resource); var landblockPhysicsPublisher = new LandblockPhysicsPublisher( d.PhysicsEngine, world.TerrainBuild.HeightTable); diff --git a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs index b713b0b4..bcb6c586 100644 --- a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs +++ b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; namespace AcDream.App.Rendering.Wb; @@ -32,6 +33,30 @@ namespace AcDream.App.Rendering.Wb; /// docs/research/named-retail/acclient.h:32035 (BuildInfo) and /// :32094 (CBldPortal). /// +internal sealed class BuildingRegistryPublication +{ + internal BuildingRegistryPublication( + BuildingInfo[] buildings, + uint landblockId, + IReadOnlyDictionary cellsByCellId) + { + Buildings = buildings; + LandblockId = landblockId; + CellsByCellId = cellsByCellId; + PreparationCommitted = buildings.Length == 0; + } + + internal BuildingInfo[] Buildings { get; } + internal uint LandblockId { get; } + internal IReadOnlyDictionary CellsByCellId { get; } + internal BuildingRegistry Registry { get; } = new(); + internal List<(LoadedCell Cell, uint BuildingId)> CellStamps { get; } = new(); + internal int BuildingCursor { get; set; } + internal uint NextBuildingId { get; set; } = 1; + internal bool PreparationCommitted { get; set; } + internal bool PublicationCommitted { get; set; } +} + public static class BuildingLoader { /// @@ -39,122 +64,167 @@ public static class BuildingLoader /// Building IDs are allocated sequentially starting at 1 (0 is reserved for /// "no building" semantics used by LoadedCell.BuildingId in RR4). /// - /// The dat-loaded for this landblock. - /// The 32-bit landblock id (e.g. 0xA9B40000). - /// The high 16 bits are ORed with each 16-bit OtherCellId to produce - /// the full cell id. - /// Pre-loaded cells keyed by full 32-bit cell id. - /// Used for BFS extension (Step B) and exit-polygon collection (Step C). - /// An empty dict is valid for unit tests; Step B and C are skipped per cell. - /// A fully populated ; never null. public static BuildingRegistry Build( LandBlockInfo info, uint landblockId, IReadOnlyDictionary cellsByCellId) { - var reg = new BuildingRegistry(); - if (info.Buildings is null || info.Buildings.Count == 0) - return reg; - - uint lbMask = landblockId & 0xFFFF0000u; - uint nextId = 1; - - foreach (var bInfo in info.Buildings) + BuildingRegistryPublication publication = PreparePublication( + info, + landblockId, + cellsByCellId); + while (!AdvancePreparationOne(publication)) { - var envCellIds = new HashSet(); - var exitPortalPolys = new List(); + } + CommitPublication(publication); + return publication.Registry; + } - // Step A: seed the cell set from BuildingInfo.Portals (entry portals). - // Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with - // the landblock prefix for the full id. - // Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in - // BuildingInfo — rare but WB guards against it too at PortalService.cs:58). - if (bInfo.Portals is not null) + internal static BuildingRegistryPublication PreparePublication( + LandBlockInfo info, + uint landblockId, + IReadOnlyDictionary cellsByCellId) + { + ArgumentNullException.ThrowIfNull(info); + ArgumentNullException.ThrowIfNull(cellsByCellId); + + int buildingCount = info.Buildings?.Count ?? 0; + var buildings = new BuildingInfo[buildingCount]; + for (int index = 0; index < buildingCount; index++) + buildings[index] = info.Buildings![index]; + return new BuildingRegistryPublication( + buildings, + landblockId, + cellsByCellId); + } + + internal static bool AdvancePreparationOne( + BuildingRegistryPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (publication.PreparationCommitted) + return true; + if (publication.BuildingCursor >= publication.Buildings.Length) + { + publication.PreparationCommitted = true; + return true; + } + + AddBuilding( + publication, + publication.Buildings[publication.BuildingCursor]); + publication.BuildingCursor++; + if (publication.BuildingCursor >= publication.Buildings.Length) + publication.PreparationCommitted = true; + return publication.PreparationCommitted; + } + + internal static void CommitPublication( + BuildingRegistryPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!publication.PreparationCommitted) + { + throw new InvalidOperationException( + "A building registry cannot publish before preparation completes."); + } + if (publication.PublicationCommitted) + return; + + foreach ((LoadedCell cell, uint buildingId) in publication.CellStamps) + cell.BuildingId = buildingId; + publication.PublicationCommitted = true; + } + + private static void AddBuilding( + BuildingRegistryPublication publication, + BuildingInfo bInfo) + { + uint lbMask = publication.LandblockId & 0xFFFF0000u; + var envCellIds = new HashSet(); + var exitPortalPolys = new List(); + + // Step A: seed the cell set from BuildingInfo.Portals. + if (bInfo.Portals is not null) + { + foreach (var portal in bInfo.Portals) { - foreach (var portal in bInfo.Portals) - { - if (portal.OtherCellId == 0xFFFF) continue; - envCellIds.Add(lbMask | portal.OtherCellId); - } - } - - // Step B: BFS through interior CellPortals to find the full cell set. - // Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per - // BFS step). Mirrors WB PortalService.cs:67-79. - // When cellsByCellId is empty (unit-test path), BFS immediately exits. - var queue = new Queue(envCellIds); - while (queue.Count > 0) - { - var current = queue.Dequeue(); - if (!cellsByCellId.TryGetValue(current, out var cell)) continue; - foreach (var p in cell.Portals) - { - if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here - uint neighbourId = lbMask | p.OtherCellId; - if (envCellIds.Add(neighbourId)) - queue.Enqueue(neighbourId); - } - } - - // Step C: collect exit portal polygons in world space. - // For each interior cell, iterate its portals; for each exit portal - // (OtherCellId == 0xFFFF), transform the portal polygon vertices from - // cell-local space to world space via WorldTransform. - // Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path). - foreach (var cellId in envCellIds) - { - if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue; - for (int pi = 0; pi < cell.Portals.Count; pi++) - { - if (cell.Portals[pi].OtherCellId != 0xFFFF) continue; - if (pi >= cell.PortalPolygons.Count) continue; - var localPoly = cell.PortalPolygons[pi]; - if (localPoly.Length < 3) continue; - var worldPoly = new Vector3[localPoly.Length]; - for (int v = 0; v < localPoly.Length; v++) - worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform); - exitPortalPolys.Add(worldPoly); - } - } - - bool hasPortalBounds = false; - var portalMin = new Vector3(float.MaxValue); - var portalMax = new Vector3(float.MinValue); - foreach (var poly in exitPortalPolys) - { - foreach (var v in poly) - { - hasPortalBounds = true; - portalMin = Vector3.Min(portalMin, v); - portalMax = Vector3.Max(portalMax, v); - } - } - - // WB PortalService.cs:89: skip buildings with no interior cells. - if (envCellIds.Count == 0) continue; - - var building = new Building - { - BuildingId = nextId++, - EnvCellIds = envCellIds, - ExitPortalPolygons = exitPortalPolys, - HasPortalBounds = hasPortalBounds, - PortalBounds = hasPortalBounds - ? new WbBoundingBox(portalMin, portalMax) - : default, - }; - reg.Add(building); - - // Step 4: stamp BuildingId on each cell (Option C — both directions - // O(1)). The internal setter on LoadedCell.BuildingId is accessible - // because this class lives in the same assembly (AcDream.App). - foreach (var cellId in envCellIds) - { - if (cellsByCellId.TryGetValue(cellId, out var cell)) - cell.BuildingId = building.BuildingId; + if (portal.OtherCellId == 0xFFFF) continue; + envCellIds.Add(lbMask | portal.OtherCellId); } } - return reg; + // Step B: BFS through interior portals. + var queue = new Queue(envCellIds); + while (queue.Count > 0) + { + uint current = queue.Dequeue(); + if (!publication.CellsByCellId.TryGetValue(current, out var cell)) + continue; + foreach (var portal in cell.Portals) + { + if (portal.OtherCellId == 0xFFFF) continue; + uint neighbourId = lbMask | portal.OtherCellId; + if (envCellIds.Add(neighbourId)) + queue.Enqueue(neighbourId); + } + } + + // Step C: collect exit portal polygons in world space. + foreach (uint cellId in envCellIds) + { + if (!publication.CellsByCellId.TryGetValue(cellId, out var cell)) + continue; + for (int portalIndex = 0; portalIndex < cell.Portals.Count; portalIndex++) + { + if (cell.Portals[portalIndex].OtherCellId != 0xFFFF) continue; + if (portalIndex >= cell.PortalPolygons.Count) continue; + Vector3[] localPolygon = cell.PortalPolygons[portalIndex]; + if (localPolygon.Length < 3) continue; + var worldPolygon = new Vector3[localPolygon.Length]; + for (int vertexIndex = 0; vertexIndex < localPolygon.Length; vertexIndex++) + { + worldPolygon[vertexIndex] = Vector3.Transform( + localPolygon[vertexIndex], + cell.WorldTransform); + } + exitPortalPolys.Add(worldPolygon); + } + } + + bool hasPortalBounds = false; + var portalMin = new Vector3(float.MaxValue); + var portalMax = new Vector3(float.MinValue); + foreach (Vector3[] polygon in exitPortalPolys) + { + foreach (Vector3 vertex in polygon) + { + hasPortalBounds = true; + portalMin = Vector3.Min(portalMin, vertex); + portalMax = Vector3.Max(portalMax, vertex); + } + } + + if (envCellIds.Count == 0) + return; + + uint buildingId = publication.NextBuildingId++; + publication.Registry.Add(new Building + { + BuildingId = buildingId, + EnvCellIds = envCellIds, + ExitPortalPolygons = exitPortalPolys, + HasPortalBounds = hasPortalBounds, + PortalBounds = hasPortalBounds + ? new WbBoundingBox(portalMin, portalMax) + : default, + }); + + // Stamps remain off-side until the complete registry commits. + foreach (uint cellId in envCellIds) + { + if (publication.CellsByCellId.TryGetValue(cellId, out var cell)) + publication.CellStamps.Add((cell, buildingId)); + } } } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs new file mode 100644 index 00000000..1b39656f --- /dev/null +++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs @@ -0,0 +1,45 @@ +using System.Numerics; + +namespace AcDream.App.Rendering.Wb; + +/// +/// Narrow update-thread publication seam used by streaming. Preparation builds +/// one private shell at a time; commit publishes the completed immutable +/// landblock snapshot with one owner dictionary replacement. +/// +public interface IEnvCellLandblockPublisher +{ + EnvCellLandblockPublication PreparePublication( + EnvCellLandblockBuild build); + + bool AdvancePreparationOne(EnvCellLandblockPublication publication); + + void CommitPublication(EnvCellLandblockPublication publication); +} + +public sealed class EnvCellLandblockPublication +{ + internal EnvCellLandblockPublication( + object owner, + EnvCellLandblockBuild build) + { + Owner = owner; + Build = build; + Replacement = new EnvCellLandblock + { + GridX = (int)((build.LandblockId >> 24) & 0xFFu), + GridY = (int)((build.LandblockId >> 16) & 0xFFu), + }; + TotalBounds = new WbBoundingBox( + new Vector3(float.MaxValue), + new Vector3(float.MinValue)); + } + + internal object Owner { get; } + internal EnvCellLandblockBuild Build { get; } + internal EnvCellLandblock Replacement { get; } + internal WbBoundingBox TotalBounds { get; set; } + internal int ShellCursor { get; set; } + internal bool PreparationCommitted { get; set; } + internal bool PublicationCommitted { get; set; } +} diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 185c5001..d7b8acc4 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -29,8 +29,11 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb; -public sealed unsafe class EnvCellRenderer : IDisposable +public sealed unsafe class EnvCellRenderer : + IDisposable, + IEnvCellLandblockPublisher { + private readonly object _publicationOwner = new(); private readonly GL _gl; private readonly ObjectMeshManager _meshManager; private readonly WbFrustum _frustum; @@ -381,8 +384,80 @@ public sealed unsafe class EnvCellRenderer : IDisposable /// public void CommitLandblock(EnvCellLandblockBuild build) { - _landblocks[build.LandblockId] = CreateCommittedSnapshot(build); + EnvCellLandblockPublication publication = PreparePublication(build); + while (!AdvancePreparationOne(publication)) + { + } + CommitPublication(publication); + } + + EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication( + EnvCellLandblockBuild build) => + PreparePublication(build); + + bool IEnvCellLandblockPublisher.AdvancePreparationOne( + EnvCellLandblockPublication publication) => + AdvancePreparationOne(publication); + + void IEnvCellLandblockPublisher.CommitPublication( + EnvCellLandblockPublication publication) => + CommitPublication(publication); + + internal EnvCellLandblockPublication PreparePublication( + EnvCellLandblockBuild build) + { + ArgumentNullException.ThrowIfNull(build); + return new EnvCellLandblockPublication(_publicationOwner, build); + } + + internal bool AdvancePreparationOne( + EnvCellLandblockPublication publication) + { + ValidatePublication(publication); + if (publication.PreparationCommitted) + return true; + + if (publication.ShellCursor < publication.Build.Shells.Length) + { + AddShell( + publication.Replacement, + publication.Build.Shells[publication.ShellCursor]); + WbBoundingBox bounds = + publication.Build.Shells[publication.ShellCursor].WorldBounds; + publication.TotalBounds = + publication.ShellCursor == 0 + ? bounds + : WbBoundingBox.Union( + publication.TotalBounds, + bounds); + publication.ShellCursor++; + return false; + } + + publication.Replacement.TotalEnvCellBounds = + publication.Replacement.EnvCellBounds.Count == 0 + ? new WbBoundingBox(Vector3.Zero, Vector3.Zero) + : publication.TotalBounds; + publication.Replacement.InstancesReady = true; + publication.Replacement.MeshDataReady = true; + publication.Replacement.GpuReady = true; + publication.PreparationCommitted = true; + return true; + } + + internal void CommitPublication( + EnvCellLandblockPublication publication) + { + ValidatePublication(publication); + if (!publication.PreparationCommitted) + throw new InvalidOperationException( + "EnvCell landblock publication cannot commit before preparation."); + if (publication.PublicationCommitted) + return; + + _landblocks[publication.Build.LandblockId] = publication.Replacement; NeedsPrepare = true; + publication.PublicationCommitted = true; } /// @@ -398,35 +473,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable }; foreach (var shell in build.Shells) - { - replacement.Instances.Add(new EnvCellSceneryInstance - { - ObjectId = shell.GeometryId, - InstanceId = shell.CellId, - IsBuilding = true, - IsEntryCell = false, - WorldPosition = shell.WorldPosition, - LocalPosition = Vector3.Zero, - Rotation = shell.Rotation, - Scale = Vector3.One, - Transform = shell.Transform, - LocalBoundingBox = shell.LocalBounds, - BoundingBox = shell.WorldBounds, - }); - replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds; - - if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances)) - { - instances = new List(); - replacement.BuildingPartGroups[shell.GeometryId] = instances; - } - instances.Add(new InstanceData - { - Transform = shell.Transform, - CellId = shell.CellId, - Flags = 0, - }); - } + AddShell(replacement, shell); var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); foreach (var bounds in replacement.EnvCellBounds.Values) @@ -441,6 +488,53 @@ public sealed unsafe class EnvCellRenderer : IDisposable return replacement; } + private static void AddShell( + EnvCellLandblock replacement, + EnvCellShellPlacement shell) + { + replacement.Instances.Add(new EnvCellSceneryInstance + { + ObjectId = shell.GeometryId, + InstanceId = shell.CellId, + IsBuilding = true, + IsEntryCell = false, + WorldPosition = shell.WorldPosition, + LocalPosition = Vector3.Zero, + Rotation = shell.Rotation, + Scale = Vector3.One, + Transform = shell.Transform, + LocalBoundingBox = shell.LocalBounds, + BoundingBox = shell.WorldBounds, + }); + replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds; + + if (!replacement.BuildingPartGroups.TryGetValue( + shell.GeometryId, + out var instances)) + { + instances = new List(); + replacement.BuildingPartGroups[shell.GeometryId] = instances; + } + instances.Add(new InstanceData + { + Transform = shell.Transform, + CellId = shell.CellId, + Flags = 0, + }); + } + + private void ValidatePublication( + EnvCellLandblockPublication publication) + { + ArgumentNullException.ThrowIfNull(publication); + if (!ReferenceEquals(publication.Owner, _publicationOwner)) + { + throw new ArgumentException( + "The EnvCell publication receipt belongs to another renderer.", + nameof(publication)); + } + } + /// /// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it. /// diff --git a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs index d107db85..8330231c 100644 --- a/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockPhysicsPublisher.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World; +using DatReaderWriter.Types; namespace AcDream.App.Streaming; @@ -15,15 +16,45 @@ public sealed class LandblockPhysicsPublication internal LandblockPhysicsPublication( object owner, LandblockBuild build, - Vector3 origin) + Vector3 origin, + uint currentCellId, + BuildingInfo[] buildings, + uint[] priorStaticOwnerIds) { Owner = owner; Build = build; Origin = origin; + CurrentCellId = currentCellId; + Buildings = buildings; + PriorStaticOwnerIds = priorStaticOwnerIds; } internal object Owner { get; } internal LandblockBuild Build { get; } + internal uint CurrentCellId { get; } + internal BuildingInfo[] Buildings { get; } + internal uint[] PriorStaticOwnerIds { get; } + internal SortedSet GfxObjectIdSet { get; } = new(); + internal uint[] GfxObjectIds { get; set; } = Array.Empty(); + internal int PreparationCursor { get; set; } + internal bool PreparationCommitted { get; set; } + internal bool PriorCacheRemoved { get; set; } + internal TerrainSurface? TerrainSurface { get; set; } + internal List CellSurfaces { get; } = new(); + internal List PortalPlanes { get; } = new(); + internal uint CellCursor { get; set; } + internal int BuildingCursor { get; set; } + internal bool BaseCommitted { get; set; } + internal int GfxCursor { get; set; } + internal int PriorStaticCursor { get; set; } + internal int StaticCursor { get; set; } + internal int BspOwnerCount { get; set; } + internal int CylinderOwnerCount { get; set; } + internal int NoCollisionCount { get; set; } + internal int SceneryTried { get; set; } + internal uint[]? RefloodOwnerIds { get; set; } + internal int RefloodCursor { get; set; } + internal bool RefloodCommitted { get; set; } internal bool BeginCommitted { get; set; } internal bool CompletionCommitted { get; set; } @@ -142,6 +173,21 @@ public sealed class LandblockPhysicsPublisher /// public LandblockPhysicsPublication PreparePublication( LandblockRenderPublication renderPublication) + { + LandblockPhysicsPublication publication = + CreatePublication(renderPublication); + while (!AdvancePreparationOne(publication)) + { + } + return publication; + } + + /// + /// Captures the immutable physics root without walking the entity suffix. + /// The concrete pipeline advances that walk under its frame meter. + /// + internal LandblockPhysicsPublication CreatePublication( + LandblockRenderPublication renderPublication) { ArgumentNullException.ThrowIfNull(renderPublication); LandblockBuild build = renderPublication.Build; @@ -149,10 +195,50 @@ public sealed class LandblockPhysicsPublisher if (!IsFinite(origin)) throw new ArgumentOutOfRangeException(nameof(renderPublication)); + PhysicsDatBundle datBundle = + build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty; + BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray() + ?? Array.Empty(); return new LandblockPhysicsPublication( _receiptOwner, build, - origin); + origin, + _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u, + buildings, + _physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock( + build.Landblock.LandblockId)); + } + + /// + /// Advances one entity's stable GfxObj-index preparation. No external + /// physics state is mutated until this cursor and the static-validation + /// cursor both complete. + /// + internal bool AdvancePreparationOne( + LandblockPhysicsPublication publication) + { + ValidateReceipt(publication); + if (publication.PreparationCommitted) + return true; + + IReadOnlyList entities = + publication.Build.Landblock.Entities; + if (publication.PreparationCursor < entities.Count) + { + WorldEntity entity = entities[publication.PreparationCursor]; + for (int i = 0; i < entity.MeshRefs.Count; i++) + { + uint id = entity.MeshRefs[i].GfxObjId; + if ((id & 0xFF000000u) == 0x01000000u) + publication.GfxObjectIdSet.Add(id); + } + publication.PreparationCursor++; + return false; + } + + publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray(); + publication.PreparationCommitted = true; + return true; } /// @@ -160,10 +246,26 @@ public sealed class LandblockPhysicsPublisher /// retained receipt. /// public void BeginPublication(LandblockPhysicsPublication publication) + { + ValidateReceipt(publication); + if (!publication.PreparationCommitted) + throw new InvalidOperationException( + "Physics publication cannot begin before preparation commits."); + while (!AdvanceBeginOne(publication)) + { + } + } + + /// + /// Advances one exact physics-prefix operation: prior-cache withdrawal, + /// terrain construction, one EnvCell, one building, or the final canonical + /// physics-engine replacement. All arrays and cursors live on the receipt. + /// + internal bool AdvanceBeginOne(LandblockPhysicsPublication publication) { ValidateReceipt(publication); if (publication.BeginCommitted) - return; + return true; long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; @@ -171,157 +273,76 @@ public sealed class LandblockPhysicsPublisher LoadedLandblock landblock = build.Landblock; PhysicsDatBundle datBundle = landblock.PhysicsDats ?? PhysicsDatBundle.Empty; - uint currentCellId = _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u; - uint landblockX = (landblock.LandblockId >> 24) & 0xFFu; - uint landblockY = (landblock.LandblockId >> 16) & 0xFFu; - - // A publication is an exact replacement for this landblock prefix. - // CacheCellStruct/CacheBuilding intentionally use retail first-wins - // semantics within one pass, so retire the prior pass before adding - // the replacement. Adjacent prefixes remain untouched. - _physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); - _physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); - _physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( - landblock.LandblockId); - - // TerrainInfo.Type occupies bits 2-6. The low byte retains those bits - // plus Road (bits 0-1), which TerrainSurface masks independently. - var terrainBytes = new byte[81]; - for (int i = 0; i < terrainBytes.Length; i++) - terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i]; - - var terrainSurface = new TerrainSurface( - landblock.Heightmap.Height, - _heightTable, - landblockX, - landblockY, - terrainBytes); - var cellSurfaces = new List(); - var portalPlanes = new List(); - DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info; - if (landblockInfo is not null && landblockInfo.NumCells > 0) + + if (!publication.PriorCacheRemoved) { - uint firstCellId = - (landblock.LandblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < landblockInfo.NumCells; offset++) + // CacheCellStruct/CacheBuilding use first-wins semantics within one + // publication, so the replacement pass starts with one exact + // landblock-scoped withdrawal. + _physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId); + _physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId); + _physicsDataCache.CellGraph.RemoveEnvCellsForLandblock( + landblock.LandblockId); + publication.PriorCacheRemoved = true; + } + else if (publication.TerrainSurface is null) + { + uint landblockX = (landblock.LandblockId >> 24) & 0xFFu; + uint landblockY = (landblock.LandblockId >> 16) & 0xFFu; + var terrainBytes = new byte[81]; + for (int i = 0; i < terrainBytes.Length; i++) + terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i]; + publication.TerrainSurface = new TerrainSurface( + landblock.Heightmap.Height, + _heightTable, + landblockX, + landblockY, + terrainBytes); + } + else if (landblockInfo is not null + && publication.CellCursor < landblockInfo.NumCells) + { + PublishCell(publication, datBundle, publication.CellCursor); + publication.CellCursor++; + } + else if (publication.BuildingCursor < publication.Buildings.Length) + { + PublishBuilding( + landblock, + datBundle, + publication.TerrainSurface, + origin, + publication.Buildings[publication.BuildingCursor]); + publication.BuildingCursor++; + } + else if (!publication.BaseCommitted) + { + _physicsEngine.AddLandblock( + landblock.LandblockId, + publication.TerrainSurface, + publication.CellSurfaces, + publication.PortalPlanes, + origin.X, + origin.Y); + if ((publication.CurrentCellId & 0xFFFF0000u) + == (landblock.LandblockId & 0xFFFF0000u)) { - uint envCellId = firstCellId + offset; - if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) - continue; - if (envCell.EnvironmentId == 0) - continue; - if (!datBundle.Environments.TryGetValue( - 0x0D000000u | envCell.EnvironmentId, - out var environment)) - { - continue; - } - if (!environment.Cells.TryGetValue( - envCell.CellStructure, - out var cellStruct)) - { - continue; - } - - Quaternion rotation = envCell.Position.Orientation; - Vector3 cellOriginWorld = envCell.Position.Origin + origin; - Matrix4x4 physicsCellTransform = - Matrix4x4.CreateFromQuaternion(rotation) - * Matrix4x4.CreateTranslation(cellOriginWorld); - - _physicsDataCache.CacheCellStruct( - envCellId, - envCell, - cellStruct, - physicsCellTransform); - - var worldVertices = new Dictionary( - cellStruct.VertexArray.Vertices.Count); - foreach ((ushort vertexId, var vertex) in - cellStruct.VertexArray.Vertices) - { - Vector3 worldPosition = - Vector3.Transform(vertex.Origin, rotation) - + cellOriginWorld; - worldVertices[(ushort)vertexId] = worldPosition; - } - - var polygonVertexIds = new List>( - cellStruct.PhysicsPolygons.Count); - foreach (var polygon in cellStruct.PhysicsPolygons.Values) - { - var vertexIds = new List(polygon.VertexIds.Count); - foreach (short vertexId in polygon.VertexIds) - vertexIds.Add(vertexId); - polygonVertexIds.Add(vertexIds); - } - cellSurfaces.Add(new CellSurface( - envCellId, - worldVertices, - polygonVertexIds)); - - // CellPortal.PolygonId indexes rendering Polygons, not - // PhysicsPolygons (ACViewer EnvCell.find_transit_cells). - foreach (var portal in envCell.CellPortals) - { - if (!cellStruct.Polygons.TryGetValue( - portal.PolygonId, - out var polygon) - || polygon.VertexIds.Count < 3) - { - continue; - } - - var portalVertices = new Vector3[polygon.VertexIds.Count]; - bool allFound = true; - for (int index = 0; index < polygon.VertexIds.Count; index++) - { - if (!worldVertices.TryGetValue( - (ushort)polygon.VertexIds[index], - out portalVertices[index])) - { - allFound = false; - break; - } - } - if (!allFound) - continue; - - portalPlanes.Add(PortalPlane.FromVertices( - portalVertices.AsSpan(), - portal.OtherCellId, - envCellId & 0xFFFFu, - (ushort)portal.Flags)); - } + _physicsEngine.UpdatePlayerCurrCell(publication.CurrentCellId); } + publication.BaseCommitted = true; } - - int buildingCount = PublishBuildings( - landblock, - datBundle, - landblockInfo, - terrainSurface, - origin); - _physicsEngine.AddLandblock( - landblock.LandblockId, - terrainSurface, - cellSurfaces, - portalPlanes, - origin.X, - origin.Y); - if ((currentCellId & 0xFFFF0000u) - == (landblock.LandblockId & 0xFFFF0000u)) + else { - _physicsEngine.UpdatePlayerCurrCell(currentCellId); + _cellSurfaceCount += publication.CellSurfaces.Count; + _portalPlaneCount += publication.PortalPlanes.Count; + _buildingCount += publication.Buildings.Length; + publication.BeginCommitted = true; + _beginCount++; } - _cellSurfaceCount += cellSurfaces.Count; - _portalPlaneCount += portalPlanes.Count; - _buildingCount += buildingCount; - publication.BeginCommitted = true; - _beginCount++; _basePublishTicks += Stopwatch.GetTimestamp() - started; + return publication.BeginCommitted; } /// @@ -338,209 +359,93 @@ public sealed class LandblockPhysicsPublisher if (!publication.BeginCommitted) throw new InvalidOperationException( "Physics publication cannot complete before its prefix commits."); - if (publication.CompletionCommitted) - return; + while (!AdvanceCompleteOne(publication, beforeStaticCollision)) + { + } + } - long completedStarted = Stopwatch.GetTimestamp(); + /// + /// Advances one exact physics-suffix operation: one GfxObj cache entry, + /// prior static withdrawal, one static light/collision entity, or reflood. + /// + internal bool AdvanceCompleteOne( + LandblockPhysicsPublication publication, + Action? beforeStaticCollision = null) + { + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Physics publication cannot complete before its prefix commits."); + if (publication.CompletionCommitted) + return true; + + long started = Stopwatch.GetTimestamp(); LoadedLandblock landblock = publication.Build.Landblock; PhysicsDatBundle datBundle = landblock.PhysicsDats ?? PhysicsDatBundle.Empty; - long cacheStarted = Stopwatch.GetTimestamp(); - foreach (WorldEntity entity in landblock.Entities) + if (publication.GfxCursor < publication.GfxObjectIds.Length) { - foreach (MeshRef meshRef in entity.MeshRefs) - { - if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) - continue; - if (datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) - _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); - } + long cacheStarted = Stopwatch.GetTimestamp(); + uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor]; + if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx)) + _physicsDataCache.CacheGfxObj(gfxObjectId, gfx); + publication.GfxCursor++; + _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; } - _gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted; - - // The immutable entity list is an exact Near snapshot. Retire every - // static owner created by the prior snapshot before registering this - // one; RegisterMultiPart can replace matching IDs but cannot discover - // owners omitted by a ForceReload/reapply. Logical-owner removal also - // clears footprints flooded across an adjacent landblock seam. - _physicsEngine.ShadowObjects.DeregisterStaticOwnersForLandblock( - landblock.LandblockId); - - int landblockBspCount = 0; - int landblockCylinderCount = 0; - int landblockNoCollisionCount = 0; - int sceneryTried = 0; - foreach (WorldEntity entity in landblock.Entities) + else if (publication.PriorStaticCursor + < publication.PriorStaticOwnerIds.Length) { + _physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock( + publication.PriorStaticOwnerIds[ + publication.PriorStaticCursor], + landblock.LandblockId); + publication.PriorStaticCursor++; + } + else if (publication.StaticCursor < landblock.Entities.Count) + { + WorldEntity entity = landblock.Entities[publication.StaticCursor]; beforeStaticCollision?.Invoke(entity); - - // Retail building shells collide exclusively through the - // per-landcell building channel. They never become ordinary - // shadow objects, even when their source Setup has cylinders. - if (entity.IsBuildingShell) - continue; - - int entityBspCount = 0; - int entityCylinderCount = 0; - uint sourcePrefix = - entity.SourceGfxObjOrSetupId & 0xFF000000u; - bool isOutdoorMesh = - (entity.Id & 0x80000000u) != 0 - || (entity.Id < 0x40000000u - && (sourcePrefix == 0x01000000u - || sourcePrefix == 0x02000000u)); - if (isOutdoorMesh) - sceneryTried++; - - IReadOnlyList bspShapes = - ShadowShapeBuilder.FromLandblockBspParts( - entity.MeshRefs, - entity.IsBuildingShell, - _physicsDataCache.GetGfxObj); - entityBspCount = bspShapes.Count; - if (entityBspCount > 0) - { - _physicsEngine.ShadowObjects.RegisterMultiPart( - entity.Id, - entity.Position, - entity.Rotation, - bspShapes, - 0u, - EntityCollisionFlags.None, - publication.Origin.X, - publication.Origin.Y, - landblock.LandblockId, - seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); - LogMultipartRegistration(landblock, entity, bspShapes); - } - - SetupPhysics? setup = - _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setup is not null && entityBspCount == 0) - { - float scale = entity.Scale > 0f ? entity.Scale : 1f; - var setupShapes = new List(); - for (int cylinderIndex = 0; - cylinderIndex < setup.CylSpheres.Count; - cylinderIndex++) - { - DatReaderWriter.Types.CylSphere cylinder = - setup.CylSpheres[cylinderIndex]; - float radius = cylinder.Radius * scale; - float baseHeight = cylinder.Height > 0f - ? cylinder.Height - : cylinder.Radius * 4f; - float height = baseHeight * scale; - if (radius <= 0f) - continue; - - Vector3 localOffset = cylinder.Origin * scale; - setupShapes.Add(new ShadowShape( - GfxObjId: entity.SourceGfxObjOrSetupId, - LocalPosition: localOffset, - LocalRotation: Quaternion.Identity, - Scale: scale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: radius, - CylHeight: height)); - } - - if (setup.CylSpheres.Count == 0) - { - for (int sphereIndex = 0; - sphereIndex < setup.Spheres.Count; - sphereIndex++) - { - DatReaderWriter.Types.Sphere sphere = - setup.Spheres[sphereIndex]; - if (sphere.Radius <= 0f) - continue; - - float radius = sphere.Radius * scale; - Vector3 localOffset = sphere.Origin * scale; - Vector3 localBaseOffset = localOffset - + Vector3.Transform( - -Vector3.UnitZ * radius, - Quaternion.Inverse(entity.Rotation)); - setupShapes.Add(new ShadowShape( - GfxObjId: entity.SourceGfxObjOrSetupId, - LocalPosition: localBaseOffset, - LocalRotation: Quaternion.Identity, - Scale: scale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: radius, - CylHeight: radius * 2f)); - } - } - - if (setup.CylSpheres.Count == 0 - && setup.Spheres.Count == 0 - && setup.Radius > 0f) - { - float radius = setup.Radius * scale; - float height = (setup.Height > 0f - ? setup.Height - : setup.Radius * 2f) * scale; - setupShapes.Add(new ShadowShape( - GfxObjId: entity.SourceGfxObjOrSetupId, - LocalPosition: Vector3.Zero, - LocalRotation: Quaternion.Identity, - Scale: scale, - CollisionType: ShadowCollisionType.Cylinder, - Radius: radius, - CylHeight: height)); - } - - if (setupShapes.Count > 0) - { - _physicsEngine.ShadowObjects.RegisterMultiPart( - entity.Id, - entity.Position, - entity.Rotation, - setupShapes, - 0u, - EntityCollisionFlags.None, - publication.Origin.X, - publication.Origin.Y, - landblock.LandblockId, - seedCellId: entity.ParentCellId ?? 0u, - isStatic: true); - LogSetupRegistration(landblock, entity, setupShapes); - entityCylinderCount = setupShapes.Count; - } - } - - if (entityBspCount > 0) - landblockBspCount++; - if (entityCylinderCount > 0) - landblockCylinderCount++; - if (entityBspCount == 0 && entityCylinderCount == 0 - && (sourcePrefix == 0x01000000u - || sourcePrefix == 0x02000000u)) - { - landblockNoCollisionCount++; - } + PublishStaticEntity(publication, entity); + publication.StaticCursor++; } - - if (PhysicsDiagnostics.ProbeBuildingEnabled && sceneryTried > 0) + else if (publication.RefloodOwnerIds is null) { - Console.WriteLine( - $"lb 0x{landblock.LandblockId:X8}: scenery tried={sceneryTried} " + - $"(outdoorNone={landblockNoCollisionCount})"); + publication.RefloodOwnerIds = + _physicsEngine.ShadowObjects + .CaptureRefloodOwnersForLandblock(landblock.LandblockId); + } + else if (publication.RefloodCursor + < publication.RefloodOwnerIds.Length) + { + _physicsEngine.ShadowObjects.RefloodOwnerForLandblock( + publication.RefloodOwnerIds[publication.RefloodCursor], + landblock.LandblockId); + publication.RefloodCursor++; + } + else if (!publication.RefloodCommitted) + { + if (PhysicsDiagnostics.ProbeBuildingEnabled + && publication.SceneryTried > 0) + { + Console.WriteLine( + $"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " + + $"(outdoorNone={publication.NoCollisionCount})"); + } + LogMissingSceneryBounds(landblock); + _refloodCount++; + publication.RefloodCommitted = true; + } + else + { + _staticBspOwnerCount += publication.BspOwnerCount; + _staticCylinderOwnerCount += publication.CylinderOwnerCount; + publication.CompletionCommitted = true; + _completeCount++; } - LogMissingSceneryBounds(landblock); - _physicsEngine.ShadowObjects.RefloodLandblock(landblock.LandblockId); - _refloodCount++; - - _staticBspOwnerCount += landblockBspCount; - _staticCylinderOwnerCount += landblockCylinderCount; - publication.CompletionCommitted = true; - _completeCount++; - _completePublishTicks += - Stopwatch.GetTimestamp() - completedStarted; + _completePublishTicks += Stopwatch.GetTimestamp() - started; + return publication.CompletionCommitted; } public void DemoteToTerrain(uint landblockId) @@ -555,54 +460,292 @@ public sealed class LandblockPhysicsPublisher _fullRemovalCount++; } - private int PublishBuildings( + private void PublishCell( + LandblockPhysicsPublication publication, + PhysicsDatBundle datBundle, + uint offset) + { + LoadedLandblock landblock = publication.Build.Landblock; + uint envCellId = + (landblock.LandblockId & 0xFFFF0000u) | (0x0100u + offset); + if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell) + || envCell.EnvironmentId == 0 + || !datBundle.Environments.TryGetValue( + 0x0D000000u | envCell.EnvironmentId, + out var environment) + || !environment.Cells.TryGetValue( + envCell.CellStructure, + out var cellStruct)) + { + return; + } + + Quaternion rotation = envCell.Position.Orientation; + Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin; + Matrix4x4 physicsCellTransform = + Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(cellOriginWorld); + + _physicsDataCache.CacheCellStruct( + envCellId, + envCell, + cellStruct, + physicsCellTransform); + + var worldVertices = new Dictionary( + cellStruct.VertexArray.Vertices.Count); + foreach ((ushort vertexId, var vertex) in + cellStruct.VertexArray.Vertices) + { + Vector3 worldPosition = + Vector3.Transform(vertex.Origin, rotation) + + cellOriginWorld; + worldVertices[(ushort)vertexId] = worldPosition; + } + + var polygonVertexIds = new List>( + cellStruct.PhysicsPolygons.Count); + foreach (var polygon in cellStruct.PhysicsPolygons.Values) + { + var vertexIds = new List(polygon.VertexIds.Count); + foreach (short vertexId in polygon.VertexIds) + vertexIds.Add(vertexId); + polygonVertexIds.Add(vertexIds); + } + + var portalPlanes = new List(); + // CellPortal.PolygonId indexes rendering Polygons, not + // PhysicsPolygons (ACViewer EnvCell.find_transit_cells). + foreach (var portal in envCell.CellPortals) + { + if (!cellStruct.Polygons.TryGetValue( + portal.PolygonId, + out var polygon) + || polygon.VertexIds.Count < 3) + { + continue; + } + + var portalVertices = new Vector3[polygon.VertexIds.Count]; + bool allFound = true; + for (int index = 0; index < polygon.VertexIds.Count; index++) + { + if (!worldVertices.TryGetValue( + (ushort)polygon.VertexIds[index], + out portalVertices[index])) + { + allFound = false; + break; + } + } + if (!allFound) + continue; + + portalPlanes.Add(PortalPlane.FromVertices( + portalVertices.AsSpan(), + portal.OtherCellId, + envCellId & 0xFFFFu, + (ushort)portal.Flags)); + } + + publication.CellSurfaces.Add(new CellSurface( + envCellId, + worldVertices, + polygonVertexIds)); + publication.PortalPlanes.AddRange(portalPlanes); + } + + private void PublishBuilding( LoadedLandblock landblock, PhysicsDatBundle datBundle, - DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo, TerrainSurface terrainSurface, - Vector3 origin) + Vector3 origin, + BuildingInfo building) { - if (landblockInfo is null || landblockInfo.Buildings.Count == 0) - return 0; - uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u; - foreach (var building in landblockInfo.Buildings) + var portals = new List(building.Portals.Count); + foreach (var portal in building.Portals) { - var portals = new List(building.Portals.Count); - foreach (var portal in building.Portals) - { - portals.Add(new BldPortalInfo( - otherCellId: landblockPrefix | (uint)portal.OtherCellId, - otherPortalId: unchecked((short)portal.OtherPortalId), - flags: (ushort)portal.Flags)); - } - - Vector3 buildingOrigin = building.Frame.Origin + origin; - Matrix4x4 buildingTransform = - Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) - * Matrix4x4.CreateTranslation(buildingOrigin); - uint landcellLow = terrainSurface.ComputeOutdoorCellId( - building.Frame.Origin.X, - building.Frame.Origin.Y); - uint landcellId = landblockPrefix | landcellLow; - - uint shellPartZero = building.ModelId; - if ((shellPartZero & 0xFF000000u) == 0x02000000u) - { - datBundle.Setups.TryGetValue( - building.ModelId, - out var setup); - shellPartZero = setup is not null && setup.Parts.Count > 0 - ? setup.Parts[0] - : 0u; - } - _physicsDataCache.CacheBuilding( - landcellId, - portals, - buildingTransform, - modelId: shellPartZero); + portals.Add(new BldPortalInfo( + otherCellId: landblockPrefix | (uint)portal.OtherCellId, + otherPortalId: unchecked((short)portal.OtherPortalId), + flags: (ushort)portal.Flags)); + } + + Vector3 buildingOrigin = building.Frame.Origin + origin; + Matrix4x4 buildingTransform = + Matrix4x4.CreateFromQuaternion(building.Frame.Orientation) + * Matrix4x4.CreateTranslation(buildingOrigin); + uint landcellLow = terrainSurface.ComputeOutdoorCellId( + building.Frame.Origin.X, + building.Frame.Origin.Y); + uint landcellId = landblockPrefix | landcellLow; + + uint shellPartZero = building.ModelId; + if ((shellPartZero & 0xFF000000u) == 0x02000000u) + { + datBundle.Setups.TryGetValue( + building.ModelId, + out var setup); + shellPartZero = setup is not null && setup.Parts.Count > 0 + ? setup.Parts[0] + : 0u; + } + _physicsDataCache.CacheBuilding( + landcellId, + portals, + buildingTransform, + modelId: shellPartZero); + } + + private void PublishStaticEntity( + LandblockPhysicsPublication publication, + WorldEntity entity) + { + LoadedLandblock landblock = publication.Build.Landblock; + // Retail building shells collide exclusively through the per-landcell + // building channel. They never become ordinary shadow objects. + if (entity.IsBuildingShell) + return; + + int entityBspCount = 0; + int entityCylinderCount = 0; + uint sourcePrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u; + bool isOutdoorMesh = + (entity.Id & 0x80000000u) != 0 + || (entity.Id < 0x40000000u + && (sourcePrefix == 0x01000000u + || sourcePrefix == 0x02000000u)); + if (isOutdoorMesh) + publication.SceneryTried++; + + IReadOnlyList bspShapes = + ShadowShapeBuilder.FromLandblockBspParts( + entity.MeshRefs, + entity.IsBuildingShell, + _physicsDataCache.GetGfxObj); + entityBspCount = bspShapes.Count; + if (entityBspCount > 0) + { + _physicsEngine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + bspShapes, + 0u, + EntityCollisionFlags.None, + publication.Origin.X, + publication.Origin.Y, + landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true); + LogMultipartRegistration(landblock, entity, bspShapes); + } + + SetupPhysics? setup = + _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setup is not null && entityBspCount == 0) + { + float scale = entity.Scale > 0f ? entity.Scale : 1f; + var setupShapes = new List(); + for (int cylinderIndex = 0; + cylinderIndex < setup.CylSpheres.Count; + cylinderIndex++) + { + CylSphere cylinder = setup.CylSpheres[cylinderIndex]; + float radius = cylinder.Radius * scale; + float baseHeight = cylinder.Height > 0f + ? cylinder.Height + : cylinder.Radius * 4f; + float height = baseHeight * scale; + if (radius <= 0f) + continue; + + Vector3 localOffset = cylinder.Origin * scale; + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: localOffset, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: height)); + } + + if (setup.CylSpheres.Count == 0) + { + for (int sphereIndex = 0; + sphereIndex < setup.Spheres.Count; + sphereIndex++) + { + Sphere sphere = setup.Spheres[sphereIndex]; + if (sphere.Radius <= 0f) + continue; + + float radius = sphere.Radius * scale; + Vector3 localOffset = sphere.Origin * scale; + Vector3 localBaseOffset = localOffset + + Vector3.Transform( + -Vector3.UnitZ * radius, + Quaternion.Inverse(entity.Rotation)); + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: localBaseOffset, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: radius * 2f)); + } + } + + if (setup.CylSpheres.Count == 0 + && setup.Spheres.Count == 0 + && setup.Radius > 0f) + { + float radius = setup.Radius * scale; + float height = (setup.Height > 0f + ? setup.Height + : setup.Radius * 2f) * scale; + setupShapes.Add(new ShadowShape( + GfxObjId: entity.SourceGfxObjOrSetupId, + LocalPosition: Vector3.Zero, + LocalRotation: Quaternion.Identity, + Scale: scale, + CollisionType: ShadowCollisionType.Cylinder, + Radius: radius, + CylHeight: height)); + } + + if (setupShapes.Count > 0) + { + _physicsEngine.ShadowObjects.RegisterMultiPart( + entity.Id, + entity.Position, + entity.Rotation, + setupShapes, + 0u, + EntityCollisionFlags.None, + publication.Origin.X, + publication.Origin.Y, + landblock.LandblockId, + seedCellId: entity.ParentCellId ?? 0u, + isStatic: true); + LogSetupRegistration(landblock, entity, setupShapes); + entityCylinderCount = setupShapes.Count; + } + } + + if (entityBspCount > 0) + publication.BspOwnerCount++; + if (entityCylinderCount > 0) + publication.CylinderOwnerCount++; + if (entityBspCount == 0 && entityCylinderCount == 0 + && (sourcePrefix == 0x01000000u + || sourcePrefix == 0x02000000u)) + { + publication.NoCollisionCount++; } - return landblockInfo.Buildings.Count; } private static void LogSetupRegistration( diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index 06c972b9..9c569091 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -14,6 +14,10 @@ public readonly record struct LandblockPresentationDiagnostics( LandblockPhysicsPublisherDiagnostics Physics, LandblockStaticPresentationDiagnostics Statics); +internal readonly record struct LandblockPublicationAdvance( + bool Completed, + bool Progressed); + /// /// Render-thread transaction coordinator for one accepted landblock streaming /// result. Streaming policy (desired tier, generation, priority, and apply @@ -48,6 +52,7 @@ public sealed class LandblockPresentationPipeline public required LandblockBuild Build; public required LandblockMeshData MeshData; public required uint LandblockId; + public required LandblockStreamCostEstimate Cost; public LandblockStreamTier Tier; public bool PresentationCommitted; public LandblockRenderPublication? RenderPublication; @@ -186,7 +191,19 @@ public sealed class LandblockPresentationPipeline ArgumentNullException.ThrowIfNull(result); if (!_publications.TryGetValue(result, out PublicationTransaction? transaction)) return; - Advance(result, transaction); + Advance(result, transaction, meter: null, ensureProgress: false); + } + + internal LandblockPublicationAdvance ResumePublication( + LandblockStreamResult result, + StreamingWorkMeter meter, + bool ensureProgress) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(meter); + if (!_publications.TryGetValue(result, out PublicationTransaction? transaction)) + return new LandblockPublicationAdvance(Completed: true, Progressed: false); + return Advance(result, transaction, meter, ensureProgress); } public void AdvanceRetirements() => _retirements.Advance(); @@ -223,8 +240,28 @@ public sealed class LandblockPresentationPipeline loaded.Build, loaded.MeshData, loaded.LandblockId, - loaded.Tier); - Advance(loaded, transaction); + loaded.Tier, + estimate: null); + Advance(loaded, transaction, meter: null, ensureProgress: false); + } + + internal LandblockPublicationAdvance PublishLoaded( + LandblockStreamResult.Loaded loaded, + LandblockStreamCostEstimate estimate, + StreamingWorkMeter meter, + bool ensureProgress) + { + ArgumentNullException.ThrowIfNull(loaded); + ArgumentNullException.ThrowIfNull(meter); + PublicationTransaction transaction = GetOrCreate( + loaded, + PublicationKind.Loaded, + loaded.Build, + loaded.MeshData, + loaded.LandblockId, + loaded.Tier, + estimate); + return Advance(loaded, transaction, meter, ensureProgress); } public void PublishPromoted( @@ -241,8 +278,32 @@ public sealed class LandblockPresentationPipeline promoted.Build, promoted.MeshData, promoted.LandblockId, - LandblockStreamTier.Near); - Advance(promoted, transaction); + LandblockStreamTier.Near, + estimate: null); + Advance(promoted, transaction, meter: null, ensureProgress: false); + } + + internal LandblockPublicationAdvance PublishPromoted( + LandblockStreamResult.Promoted promoted, + bool mergeIntoExistingLandblock, + LandblockStreamCostEstimate estimate, + StreamingWorkMeter meter, + bool ensureProgress) + { + ArgumentNullException.ThrowIfNull(promoted); + ArgumentNullException.ThrowIfNull(meter); + PublicationKind kind = mergeIntoExistingLandblock + ? PublicationKind.PromoteExisting + : PublicationKind.PromoteSelfContained; + PublicationTransaction transaction = GetOrCreate( + promoted, + kind, + promoted.Build, + promoted.MeshData, + promoted.LandblockId, + LandblockStreamTier.Near, + estimate); + return Advance(promoted, transaction, meter, ensureProgress); } public void PublishAsFar( @@ -270,6 +331,11 @@ public sealed class LandblockPresentationPipeline Origin: completedBuild.Origin), MeshData = meshData, LandblockId = farLandblock.LandblockId, + Cost = LandblockStreamResultCost.Estimate( + new LandblockBuild( + farLandblock, + Origin: completedBuild.Origin), + meshData), Tier = LandblockStreamTier.Far, }; _publications.Add(acceptedResult, transaction); @@ -280,7 +346,56 @@ public sealed class LandblockPresentationPipeline $"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending."); } - Advance(acceptedResult, transaction); + Advance( + acceptedResult, + transaction, + meter: null, + ensureProgress: false); + } + + internal LandblockPublicationAdvance PublishAsFar( + LandblockStreamResult acceptedResult, + LandblockBuild completedBuild, + LandblockMeshData meshData, + StreamingWorkMeter meter, + bool ensureProgress) + { + ArgumentNullException.ThrowIfNull(acceptedResult); + ArgumentNullException.ThrowIfNull(completedBuild); + ArgumentNullException.ThrowIfNull(meshData); + ArgumentNullException.ThrowIfNull(meter); + + if (!_publications.TryGetValue( + acceptedResult, + out PublicationTransaction? transaction)) + { + LoadedLandblock completed = completedBuild.Landblock; + var farLandblock = new LoadedLandblock( + completed.LandblockId, + completed.Heightmap, + Array.Empty(), + PhysicsDatBundle.Empty); + LandblockBuild farBuild = new( + farLandblock, + Origin: completedBuild.Origin); + transaction = new PublicationTransaction + { + Kind = PublicationKind.Far, + Build = farBuild, + MeshData = meshData, + LandblockId = farLandblock.LandblockId, + Cost = LandblockStreamResultCost.Estimate(farBuild, meshData), + Tier = LandblockStreamTier.Far, + }; + _publications.Add(acceptedResult, transaction); + } + else if (transaction.Kind != PublicationKind.Far) + { + throw new InvalidOperationException( + $"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending."); + } + + return Advance(acceptedResult, transaction, meter, ensureProgress); } private PublicationTransaction GetOrCreate( @@ -289,7 +404,8 @@ public sealed class LandblockPresentationPipeline LandblockBuild build, LandblockMeshData meshData, uint landblockId, - LandblockStreamTier tier) + LandblockStreamTier tier, + LandblockStreamCostEstimate? estimate) { if (_publications.TryGetValue(result, out PublicationTransaction? existing)) { @@ -311,71 +427,317 @@ public sealed class LandblockPresentationPipeline Build = build, MeshData = meshData, LandblockId = landblockId, + Cost = estimate + ?? LandblockStreamResultCost.Estimate(build, meshData), Tier = tier, }; _publications.Add(result, created); return created; } - private void Advance( + private LandblockPublicationAdvance Advance( LandblockStreamResult result, - PublicationTransaction transaction) + PublicationTransaction transaction, + StreamingWorkMeter? meter, + bool ensureProgress) { + bool progressed = false; + bool TryRun( + StreamingWorkCost cost, + string stage, + Action operation) + { + if (meter is null) + { + operation(); + progressed = true; + return true; + } + + StreamingWorkAdmission admission = meter.TryReserve( + cost, + stage, + ensureProgress && !progressed); + if (admission == StreamingWorkAdmission.Yielded) + return false; + try + { + operation(); + meter.Complete(); + progressed = true; + return true; + } + catch + { + meter.Fail(); + throw; + } + } + if (!transaction.PresentationCommitted) { if (_renderPublisher is not null && _physicsPublisher is not null && _staticPublisher is not null) { - transaction.RenderPublication ??= - _renderPublisher.PreparePublication( - transaction.Build, - transaction.MeshData); - transaction.PhysicsPublication ??= - _physicsPublisher.PreparePublication( - transaction.RenderPublication); - transaction.StaticPublication ??= - _staticPublisher.PreparePublication( - transaction.PhysicsPublication); + while (transaction.RenderPublication is null) + { + if (!TryRun( + new StreamingWorkCost(EntityOperations: 1), + "publication-prepare-render", + () => transaction.RenderPublication = + _renderPublisher.PreparePublication( + transaction.Build, + transaction.MeshData))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (transaction.PhysicsPublication is null) + { + if (!TryRun( + default, + "publication-prepare-physics", + () => transaction.PhysicsPublication = + _physicsPublisher.CreatePublication( + transaction.RenderPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (!transaction.PhysicsPublication.PreparationCommitted) + { + int entityOperations = + transaction.PhysicsPublication.PreparationCursor + < transaction.Build.Landblock.Entities.Count + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-index-physics", + () => _physicsPublisher.AdvancePreparationOne( + transaction.PhysicsPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (transaction.StaticPublication is null) + { + if (!TryRun( + default, + "publication-prepare-statics", + () => transaction.StaticPublication = + _staticPublisher.CreatePublication( + transaction.PhysicsPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (!transaction.StaticPublication.PreparationCommitted) + { + int entityOperations = + transaction.StaticPublication.PreparationCursor + < transaction.Build.Landblock.Entities.Count + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-validate-statics", + () => _staticPublisher.AdvancePreparationOne( + transaction.StaticPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } // Prepare every immutable receipt before the first external // mutation. Cross-owner identity/data errors therefore block // the publication without leaving a committed prefix. - _renderPublisher.BeginPublication(transaction.RenderPublication); - _physicsPublisher.BeginPublication(transaction.PhysicsPublication); + while (!transaction.RenderPublication.BeginCommitted) + { + StreamingWorkCost cost = + !transaction.RenderPublication.TerrainCommitted + ? new StreamingWorkCost( + GpuUploadBytes: + transaction.Cost.TerrainPayloadBytes) + : !transaction.RenderPublication.VisibilityCommitted + ? new StreamingWorkCost( + EntityOperations: + transaction.Cost.VisibilityCells) + : default; + if (!TryRun( + cost, + "publication-render-prefix", + () => _renderPublisher.AdvanceBeginOne( + transaction.RenderPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (!transaction.PhysicsPublication.BeginCommitted) + { + int entityOperations = + transaction.PhysicsPublication.TerrainSurface is not null + && transaction.PhysicsPublication.Build.Landblock.PhysicsDats? + .Info is { } info + && transaction.PhysicsPublication.CellCursor < info.NumCells + ? 1 + : transaction.PhysicsPublication.BuildingCursor + < transaction.PhysicsPublication.Buildings.Length + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-physics-prefix", + () => _physicsPublisher.AdvanceBeginOne( + transaction.PhysicsPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } // Retail establishes the building/visible-cell suffix before // each static object publishes lights, collision, and scripts. - _renderPublisher.CompletePublication( - transaction.RenderPublication); + while (!transaction.RenderPublication.CompletionCommitted) + { + int entityOperations = + transaction.RenderPublication.BuildingPublication is + { PreparationCommitted: false } buildingPublication + && buildingPublication.BuildingCursor + < buildingPublication.Buildings.Length + ? 1 + : transaction.RenderPublication.EnvCellPublication is + { PreparationCommitted: false } envCellPublication + && envCellPublication.ShellCursor + < envCellPublication.Build.Shells.Length + ? 1 + : !transaction.RenderPublication.EnvCellsCommitted + && transaction.RenderPublication + .EnvCellPublication is null + ? transaction.Cost.EnvCellShells + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-render-suffix", + () => _renderPublisher.AdvanceCompleteOne( + transaction.RenderPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } - _staticPublisher.BeginPublication(transaction.StaticPublication); - _physicsPublisher.CompletePublication( - transaction.PhysicsPublication, - entity => _staticPublisher.PrepareEntityBeforeCollision( - transaction.StaticPublication, - entity)); - _staticPublisher.CompletePublication( - transaction.StaticPublication); + while (!transaction.StaticPublication.BeginCommitted) + { + int entityOperations = + transaction.StaticPublication.PriorCleanupCursor + < transaction.StaticPublication + .OrderedPreviouslyActiveIds.Count + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-static-cleanup", + () => _staticPublisher.AdvanceBeginOne( + transaction.StaticPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (!transaction.PhysicsPublication.CompletionCommitted) + { + int entityOperations = + transaction.PhysicsPublication.GfxCursor + < transaction.PhysicsPublication.GfxObjectIds.Length + ? 1 + : transaction.PhysicsPublication.PriorStaticCursor + < transaction.PhysicsPublication + .PriorStaticOwnerIds.Length + ? 1 + : transaction.PhysicsPublication.StaticCursor + < transaction.Build.Landblock.Entities.Count + ? 1 + : transaction.PhysicsPublication + .RefloodOwnerIds is not null + && transaction.PhysicsPublication + .RefloodCursor + < transaction.PhysicsPublication + .RefloodOwnerIds.Length + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-physics-statics", + () => _physicsPublisher.AdvanceCompleteOne( + transaction.PhysicsPublication, + entity => + _staticPublisher + .PrepareEntityBeforeCollision( + transaction.StaticPublication, + entity)))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } + while (!transaction.StaticPublication.CompletionCommitted) + { + int entityOperations = + transaction.StaticPublication.PluginCursor + < transaction.StaticPublication.OrderedSnapshots.Count + ? 1 + : 0; + if (!TryRun( + new StreamingWorkCost( + EntityOperations: entityOperations), + "publication-plugin-projection", + () => _staticPublisher.AdvanceCompleteOne( + transaction.StaticPublication))) + { + return new LandblockPublicationAdvance(false, progressed); + } + } } else { - try + StreamingWorkCost legacyCost = new( + EntityOperations: transaction.Cost.Entities, + GpuUploadBytes: transaction.Cost.TerrainPayloadBytes); + bool ran = TryRun( + legacyCost, + "publication-legacy-presentation", + () => + { + try + { + (_publishBeforeSpatialCommit + ?? throw new InvalidOperationException( + "The presentation pipeline has no publication owners."))( + transaction.Build, + transaction.MeshData); + } + catch (Exception error) + { + // Compatibility callbacks cannot report an exact retained + // receipt. Preserve the established fail-fast rule only + // for callbacks that explicitly report committed mutation. + if (error is StreamingMutationException + { + MutationCommitted: true, + }) + { + _publications.Remove(result); + } + throw; + } + }); + if (!ran) { - (_publishBeforeSpatialCommit - ?? throw new InvalidOperationException( - "The presentation pipeline has no publication owners."))( - transaction.Build, - transaction.MeshData); - } - catch (Exception error) - { - // Compatibility callbacks cannot report an exact retained - // receipt. Preserve the established fail-fast rule only - // for callbacks that explicitly report committed mutation. - if (error is StreamingMutationException { MutationCommitted: true }) - _publications.Remove(result); - throw; + return new LandblockPublicationAdvance(false, progressed); } } transaction.PresentationCommitted = true; @@ -387,46 +749,70 @@ public sealed class LandblockPresentationPipeline // boundary: bucket mutation, pin/script reconciliation, then the // outer spatial observer commit. If pinning throws, the receipt is // retained and only that suffix retries. - using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch(); - if (!transaction.SpatialCommitted) - { - IEnumerable? renderIds = - transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId); - transaction.SpatialPublication = transaction.Kind switch - { - PublicationKind.Loaded - or PublicationKind.PromoteSelfContained - or PublicationKind.Far => - _state.CommitLandblockSpatial( - transaction.Build.Landblock, - renderIds, - transaction.Tier), - PublicationKind.PromoteExisting => - _state.CommitEntitiesToExistingLandblockSpatial( - transaction.LandblockId, - transaction.Build.Landblock.Entities, - renderIds), - _ => throw new InvalidOperationException( - $"Unknown landblock publication kind {transaction.Kind}."), - }; - transaction.SpatialCommitted = true; - } + if (!TryRun( + default, + "publication-spatial-commit", + () => + { + using GpuWorldState.MutationBatch mutation = + _state.BeginMutationBatch(); + if (!transaction.SpatialCommitted) + { + IEnumerable? renderIds = + transaction.Build.EnvCells?.Shells.Select( + static shell => shell.GeometryId); + transaction.SpatialPublication = transaction.Kind switch + { + PublicationKind.Loaded + or PublicationKind.PromoteSelfContained + or PublicationKind.Far => + _state.CommitLandblockSpatial( + transaction.Build.Landblock, + renderIds, + transaction.Tier), + PublicationKind.PromoteExisting => + _state.CommitEntitiesToExistingLandblockSpatial( + transaction.LandblockId, + transaction.Build.Landblock.Entities, + renderIds), + _ => throw new InvalidOperationException( + $"Unknown landblock publication kind {transaction.Kind}."), + }; + transaction.SpatialCommitted = true; + } - _state.ActivateLandblockPresentation( - transaction.SpatialPublication - ?? throw new InvalidOperationException( - "A committed spatial publication has no activation receipt.")); - transaction.SpatialPresentationCommitted = true; + _state.ActivateLandblockPresentation( + transaction.SpatialPublication + ?? throw new InvalidOperationException( + "A committed spatial publication has no activation receipt.")); + transaction.SpatialPresentationCommitted = true; + })) + { + return new LandblockPublicationAdvance(false, progressed); + } } if (!transaction.EnvCellReplayCommitted) { - if (transaction.Kind != PublicationKind.Far - && transaction.Build.EnvCells is { Shells.Length: > 0 } envCells) + if (!TryRun( + new StreamingWorkCost( + EntityOperations: transaction.Cost.EnvCellShells), + "publication-envcell-replay", + () => + { + if (transaction.Kind != PublicationKind.Far + && transaction.Build.EnvCells is + { + Shells.Length: > 0, + } envCells) + { + _ensureEnvCellMeshes?.Invoke(envCells); + } + transaction.EnvCellReplayCommitted = true; + })) { - _ensureEnvCellMeshes?.Invoke(envCells); + return new LandblockPublicationAdvance(false, progressed); } - transaction.EnvCellReplayCommitted = true; } if (!transaction.LiveRecoveryCommitted) @@ -434,10 +820,20 @@ public sealed class LandblockPresentationPipeline // Retained live projections can materialize only after the // canonical bucket exists. Landblock load never reconstructs their // identity or replays create-time resources. - _onLandblockLoaded?.Invoke(transaction.LandblockId); - transaction.LiveRecoveryCommitted = true; + if (!TryRun( + default, + "publication-live-recovery", + () => + { + _onLandblockLoaded?.Invoke(transaction.LandblockId); + transaction.LiveRecoveryCommitted = true; + })) + { + return new LandblockPublicationAdvance(false, progressed); + } } _publications.Remove(result); + return new LandblockPublicationAdvance(true, progressed); } } diff --git a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs index 3b768918..5578bc1a 100644 --- a/src/AcDream.App/Streaming/LandblockRenderPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockRenderPublisher.cs @@ -24,7 +24,9 @@ public sealed class LandblockRenderPublication Vector3 origin, Dictionary visibilityCells, Vector3 aabbMin, - Vector3 aabbMax) + Vector3 aabbMax, + BuildingRegistryPublication? buildingPublication, + EnvCellLandblockPublication? envCellPublication) { Owner = owner; Build = build; @@ -33,6 +35,8 @@ public sealed class LandblockRenderPublication _visibilityCells = new ReadOnlyDictionary(visibilityCells); AabbMin = aabbMin; AabbMax = aabbMax; + BuildingPublication = buildingPublication; + EnvCellPublication = envCellPublication; } internal object Owner { get; } @@ -40,6 +44,8 @@ public sealed class LandblockRenderPublication internal LandblockMeshData MeshData { get; } internal Vector3 AabbMin { get; } internal Vector3 AabbMax { get; } + internal BuildingRegistryPublication? BuildingPublication { get; } + internal EnvCellLandblockPublication? EnvCellPublication { get; } internal bool TerrainCommitted { get; set; } internal bool VisibilityCommitted { get; set; } internal bool AabbCommitted { get; set; } @@ -92,6 +98,7 @@ public sealed class LandblockRenderPublisher private readonly CellVisibility _cellVisibility; private readonly GpuWorldState _worldState; private readonly Action? _commitEnvCells; + private readonly IEnvCellLandblockPublisher? _envCellPublisher; private readonly Action? _prepareEnvCells; private readonly Action? _removeEnvCells; private readonly Dictionary _buildingRegistries = new(); @@ -114,7 +121,8 @@ public sealed class LandblockRenderPublisher GpuWorldState worldState, Action? commitEnvCells = null, Action? prepareEnvCells = null, - Action? removeEnvCells = null) + Action? removeEnvCells = null, + IEnvCellLandblockPublisher? envCellPublisher = null) { ArgumentNullException.ThrowIfNull(publishTerrain); ArgumentNullException.ThrowIfNull(removeTerrain); @@ -126,6 +134,13 @@ public sealed class LandblockRenderPublisher _cellVisibility = cellVisibility; _worldState = worldState; _commitEnvCells = commitEnvCells; + _envCellPublisher = envCellPublisher; + if (_commitEnvCells is not null && _envCellPublisher is not null) + { + throw new ArgumentException( + "Supply either the retained EnvCell publisher or the compatibility callback, not both.", + nameof(envCellPublisher)); + } _prepareEnvCells = prepareEnvCells; _removeEnvCells = removeEnvCells; } @@ -197,6 +212,17 @@ public sealed class LandblockRenderPublisher visibilityCells[cell.CellId] = cell; } (Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin); + BuildingRegistryPublication? buildingPublication = + build.Landblock.PhysicsDats?.Info is { } info + ? BuildingLoader.PreparePublication( + info, + landblockId, + visibilityCells) + : null; + EnvCellLandblockPublication? envCellPublication = + build.EnvCells is { } retainedEnvCells + ? _envCellPublisher?.PreparePublication(retainedEnvCells) + : null; return new LandblockRenderPublication( _receiptOwner, build, @@ -204,7 +230,9 @@ public sealed class LandblockRenderPublisher origin, visibilityCells, aabbMin, - aabbMax); + aabbMax, + buildingPublication, + envCellPublication); } /// @@ -214,13 +242,25 @@ public sealed class LandblockRenderPublisher public void BeginPublication(LandblockRenderPublication publication) { ValidateReceipt(publication); - if (publication.BeginCommitted) - return; + while (!AdvanceBeginOne(publication)) + { + } + } - long beginStarted = Stopwatch.GetTimestamp(); + /// + /// Advances one exact render-prefix mutation from the retained receipt. + /// Terrain, visibility, and AABB publication remain ordered and retries do + /// not replay an already committed prefix. + /// + internal bool AdvanceBeginOne(LandblockRenderPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return true; + + long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; uint landblockId = publication.LandblockId; - if (!publication.TerrainCommitted) { long terrainStarted = Stopwatch.GetTimestamp(); @@ -228,15 +268,13 @@ public sealed class LandblockRenderPublisher _terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted; publication.TerrainCommitted = true; } - - if (!publication.VisibilityCommitted) + else if (!publication.VisibilityCommitted) { if (build.EnvCells is { } envCells) _cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells); publication.VisibilityCommitted = true; } - - if (!publication.AabbCommitted) + else if (!publication.AabbCommitted) { _worldState.SetLandblockAabb( landblockId, @@ -244,10 +282,14 @@ public sealed class LandblockRenderPublisher publication.AabbMax); publication.AabbCommitted = true; } + else + { + publication.BeginCommitted = true; + _beginCount++; + } - publication.BeginCommitted = true; - _beginCount++; - _beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted; + _beginPublishTicks += Stopwatch.GetTimestamp() - started; + return publication.BeginCommitted; } /// @@ -256,40 +298,77 @@ public sealed class LandblockRenderPublisher /// receives the complete immutable transaction. /// public void CompletePublication(LandblockRenderPublication publication) + { + ValidateReceipt(publication); + if (!publication.BeginCommitted) + throw new InvalidOperationException( + "Render publication cannot complete before its prefix commits."); + while (!AdvanceCompleteOne(publication)) + { + } + } + + /// + /// Advances one exact render-suffix mutation. The building registry is + /// visible before the EnvCell renderer snapshot, matching the existing + /// retail-rooted ordering. + /// + internal bool AdvanceCompleteOne(LandblockRenderPublication publication) { ValidateReceipt(publication); if (!publication.BeginCommitted) throw new InvalidOperationException( "Render publication cannot complete before its prefix commits."); if (publication.CompletionCommitted) - return; + return true; long started = Stopwatch.GetTimestamp(); LandblockBuild build = publication.Build; uint landblockId = publication.LandblockId; - if (!publication.BuildingRegistryCommitted) + if (publication.BuildingPublication is { PreparationCommitted: false } + buildingPublication) { - if (build.Landblock.PhysicsDats?.Info is { } info) + BuildingLoader.AdvancePreparationOne(buildingPublication); + } + else if (!publication.BuildingRegistryCommitted) + { + if (publication.BuildingPublication is { } completedBuildings) { + BuildingLoader.CommitPublication(completedBuildings); uint registryKey = landblockId & 0xFFFF0000u; - _buildingRegistries[registryKey] = BuildingLoader.Build( - info, - landblockId, - publication.VisibilityCells); + _buildingRegistries[registryKey] = + completedBuildings.Registry; } publication.BuildingRegistryCommitted = true; } - - if (!publication.EnvCellsCommitted) + else if (publication.EnvCellPublication is { } envCellPublication + && !envCellPublication.PreparationCommitted) { - if (build.EnvCells is { } envCells) + _envCellPublisher?.AdvancePreparationOne(envCellPublication); + } + else if (!publication.EnvCellsCommitted) + { + if (publication.EnvCellPublication is { } retained) + { + (_envCellPublisher + ?? throw new InvalidOperationException( + "A retained EnvCell receipt has no owning publisher.")) + .CommitPublication(retained); + } + else if (build.EnvCells is { } envCells) + { _commitEnvCells?.Invoke(envCells); + } publication.EnvCellsCommitted = true; } + else + { + publication.CompletionCommitted = true; + _completeCount++; + } - publication.CompletionCommitted = true; - _completeCount++; _completePublishTicks += Stopwatch.GetTimestamp() - started; + return publication.CompletionCommitted; } /// diff --git a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs index 5d4081bb..ac9e0121 100644 --- a/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs +++ b/src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs @@ -13,26 +13,46 @@ namespace AcDream.App.Streaming; /// public sealed class LandblockStaticPresentationPublication { - private readonly IReadOnlyDictionary _entities; - private readonly IReadOnlyDictionary _snapshots; + private readonly Dictionary _entities; + private readonly Dictionary _snapshots; + private readonly uint[] _orderedPreviouslyActiveIds; + private readonly Dictionary _replacementActive; internal LandblockStaticPresentationPublication( object owner, LandblockPhysicsPublication physicsPublication, Dictionary entities, Dictionary snapshots, - HashSet previouslyActiveIds) + IReadOnlyDictionary priorActive, + HashSet previouslyActiveIds, + uint[] orderedPreviouslyActiveIds) { Owner = owner; PhysicsPublication = physicsPublication; _entities = entities; _snapshots = snapshots; + PriorActive = priorActive; PreviouslyActiveIds = previouslyActiveIds; + _orderedPreviouslyActiveIds = orderedPreviouslyActiveIds; + _replacementActive = new Dictionary( + physicsPublication.Build.Landblock.Entities.Count); } internal object Owner { get; } internal LandblockPhysicsPublication PhysicsPublication { get; } + internal IReadOnlyDictionary PriorActive { get; } + internal Dictionary MutableEntities => _entities; + internal Dictionary MutableSnapshots => _snapshots; internal HashSet PreviouslyActiveIds { get; } + internal IReadOnlyList OrderedPreviouslyActiveIds => + _orderedPreviouslyActiveIds; + internal IReadOnlyList> + OrderedSnapshots { get; set; } = + Array.Empty>(); + internal Dictionary ReplacementActive => + _replacementActive; + internal int PreparationCursor { get; set; } + internal bool PreparationCommitted { get; set; } internal int PriorCleanupCursor { get; set; } internal int PluginCursor { get; set; } internal bool BeginCommitted { get; set; } @@ -116,6 +136,22 @@ public sealed class LandblockStaticPresentationPublisher public LandblockStaticPresentationPublication PreparePublication( LandblockPhysicsPublication physicsPublication) + { + LandblockStaticPresentationPublication publication = + CreatePublication(physicsPublication); + while (!AdvancePreparationOne(publication)) + { + } + return publication; + } + + /// + /// Captures prior ownership without validating the replacement entity + /// list. The concrete pipeline advances replacement validation one entity + /// at a time before any render/physics owner mutates external state. + /// + internal LandblockStaticPresentationPublication CreatePublication( + LandblockPhysicsPublication physicsPublication) { ArgumentNullException.ThrowIfNull(physicsPublication); @@ -125,11 +161,43 @@ public sealed class LandblockStaticPresentationPublisher out Dictionary? active); var entities = new Dictionary(); var snapshots = new Dictionary(); - foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities) + HashSet previous = active is not null + ? active.Keys.ToHashSet() + : new HashSet(); + uint[] orderedPrevious = previous.Order().ToArray(); + return new LandblockStaticPresentationPublication( + _receiptOwner, + physicsPublication, + entities, + snapshots, + active ?? new Dictionary(), + previous, + orderedPrevious); + } + + /// + /// Validates and snapshots one replacement entity. The final stable plugin + /// order is materialized only after the cursor reaches the immutable tail. + /// + internal bool AdvancePreparationOne( + LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (publication.PreparationCommitted) + return true; + + IReadOnlyList source = + publication.PhysicsPublication.Build.Landblock.Entities; + uint canonical = Canonicalize(publication.LandblockId); + if (publication.PreparationCursor < source.Count) { + WorldEntity entity = source[publication.PreparationCursor]; if (entity.ServerGuid != 0) - continue; - if (!entities.TryAdd(entity.Id, entity)) + { + publication.PreparationCursor++; + return false; + } + if (publication.Entities.ContainsKey(entity.Id)) { throw new InvalidOperationException( $"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " + @@ -142,8 +210,9 @@ public sealed class LandblockStaticPresentationPublisher $"DAT-static ID 0x{entity.Id:X8} is already owned by " + $"landblock 0x{owner:X8}."); } - if (active is not null - && active.TryGetValue(entity.Id, out WorldEntitySnapshot retained) + if (publication.PriorActive.TryGetValue( + entity.Id, + out WorldEntitySnapshot retained) && retained.SourceId != entity.SourceGfxObjOrSetupId) { throw new InvalidOperationException( @@ -152,18 +221,17 @@ public sealed class LandblockStaticPresentationPublisher $"0x{entity.SourceGfxObjOrSetupId:X8}."); } - snapshots.Add(entity.Id, Snapshot(entity)); + publication.MutableEntities.Add(entity.Id, entity); + publication.MutableSnapshots.Add(entity.Id, Snapshot(entity)); + publication.PreparationCursor++; + return false; } - HashSet previous = active is not null - ? active.Keys.ToHashSet() - : new HashSet(); - return new LandblockStaticPresentationPublication( - _receiptOwner, - physicsPublication, - entities, - snapshots, - previous); + publication.OrderedSnapshots = publication.Snapshots + .OrderBy(static pair => pair.Key) + .ToArray(); + publication.PreparationCommitted = true; + return true; } /// @@ -174,13 +242,31 @@ public sealed class LandblockStaticPresentationPublisher public void BeginPublication(LandblockStaticPresentationPublication publication) { ValidateReceipt(publication); - if (publication.BeginCommitted) - return; - - uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray(); - while (publication.PriorCleanupCursor < priorIds.Length) + if (!publication.PreparationCommitted) + throw new InvalidOperationException( + "Static presentation cannot begin before preparation commits."); + while (!AdvanceBeginOne(publication)) { - uint id = priorIds[publication.PriorCleanupCursor]; + } + } + + /// + /// Advances exactly one retained prior-owner cleanup. The cursor advances + /// only after the complete owner operation succeeds, so a failure retries + /// the same ID without rebuilding or reordering the work list. + /// + internal bool AdvanceBeginOne( + LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (publication.BeginCommitted) + return true; + + if (publication.PriorCleanupCursor + < publication.OrderedPreviouslyActiveIds.Count) + { + uint id = publication.OrderedPreviouslyActiveIds[ + publication.PriorCleanupCursor]; bool retained = publication.Entities.ContainsKey(id); _lighting.UnregisterOwner(id, forgetState: !retained); if (!retained) @@ -192,10 +278,12 @@ public sealed class LandblockStaticPresentationPublisher _pluginRemovalCount++; } publication.PriorCleanupCursor++; + return false; } publication.BeginCommitted = true; _beginCount++; + return true; } /// @@ -258,15 +346,32 @@ public sealed class LandblockStaticPresentationPublisher throw new InvalidOperationException( "Static presentation requires completed physics publication."); } - if (publication.CompletionCommitted) - return; - - KeyValuePair[] snapshots = publication.Snapshots - .OrderBy(pair => pair.Key) - .ToArray(); - while (publication.PluginCursor < snapshots.Length) + while (!AdvanceCompleteOne(publication)) { - (uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor]; + } + } + + /// + /// Advances exactly one plugin projection, then atomically publishes the + /// retained replacement dictionary after the cursor reaches its tail. + /// + internal bool AdvanceCompleteOne( + LandblockStaticPresentationPublication publication) + { + ValidateReceipt(publication); + if (!publication.BeginCommitted + || !publication.PhysicsPublication.CompletionCommitted) + { + throw new InvalidOperationException( + "Static presentation requires completed physics publication."); + } + if (publication.CompletionCommitted) + return true; + + if (publication.PluginCursor < publication.OrderedSnapshots.Count) + { + (uint id, WorldEntitySnapshot snapshot) = + publication.OrderedSnapshots[publication.PluginCursor]; _worldState.Add(snapshot); if (publication.PreviouslyActiveIds.Contains(id)) { @@ -279,21 +384,19 @@ public sealed class LandblockStaticPresentationPublisher _pluginSpawnCount++; } _landblockByEntityId[id] = Canonicalize(publication.LandblockId); + publication.ReplacementActive[id] = snapshot; publication.PluginCursor++; + return false; } uint canonical = Canonicalize(publication.LandblockId); - if (publication.Snapshots.Count == 0) - { + if (publication.ReplacementActive.Count == 0) _activeByLandblock.Remove(canonical); - } else - { - _activeByLandblock[canonical] = publication.Snapshots - .ToDictionary(pair => pair.Key, pair => pair.Value); - } + _activeByLandblock[canonical] = publication.ReplacementActive; publication.CompletionCommitted = true; _completeCount++; + return true; } public void RemoveLighting(WorldEntity entity) diff --git a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs index 8b7a7d26..97642e07 100644 --- a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs +++ b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs @@ -29,18 +29,7 @@ internal readonly record struct StreamingQueuedCompletion( StreamingCompletionPriority Priority, ulong Generation, long Sequence, - long EnqueuedTimestamp) -{ - public StreamingWorkCost AdmissionCost => new( - CompletionAdmissions: Estimate.Work.CompletionAdmissions, - AdoptedCpuBytes: Estimate.Work.AdoptedCpuBytes); - - public StreamingWorkCost ExecutionCost => new( - EntityOperations: Estimate.Work.EntityOperations, - GpuUploadBytes: Estimate.Work.GpuUploadBytes, - GlRetireOperations: - Result is LandblockStreamResult.Unloaded ? 1 : 0); -} + long EnqueuedTimestamp); internal readonly record struct StreamingCompletionQueueSnapshot( int Count, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 900a924e..4e3d502d 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -370,7 +370,11 @@ public sealed class StreamingController : IStreamingFrameBackend // the desired-tier snapshot below is computed. Otherwise a landblock // that becomes spatially resident during convergence is absent from // the reconfiguration mutation ledger. - ConvergePendingPublications(); + if (!ConvergePendingPublications()) + { + _deferredRadiiRequest = (nearRadius, farRadius); + return; + } if (_collapsed || _region is null) { @@ -490,6 +494,12 @@ public sealed class StreamingController : IStreamingFrameBackend _presentation.GetPendingPublicationResults(); if (pending.Count == 0) return true; + if (_activeWorkMeter is null) + { + // Settings/native callbacks may request policy changes between + // frames, but publication remains owned by the frame-scoped meter. + return false; + } try { @@ -497,15 +507,14 @@ public sealed class StreamingController : IStreamingFrameBackend for (int i = 0; i < pending.Count; i++) { LandblockStreamResult result = pending[i]; - if (!TryObserveResultOperation( - result, - "publication-retry", - () => _presentation.ResumePublication(result), - ensureProgress: !progressed)) - { + LandblockPublicationAdvance advance = + _presentation.ResumePublication( + result, + _activeWorkMeter, + ensureProgress: !progressed); + progressed |= advance.Progressed; + if (!advance.Completed) return false; - } - progressed = true; } return true; } @@ -1487,23 +1496,16 @@ public sealed class StreamingController : IStreamingFrameBackend StreamingQueuedCompletion work = completion ?? throw new InvalidOperationException( "The completion queue returned a null head."); - StreamingWorkCost executionCost = - IsStaleGeneration(work.Result) - ? default - : work.ExecutionCost; - StreamingWorkAdmission admission = meter.TryReserve( - executionCost, - $"execute-{work.Priority}", - ensureProgress: !executed); - if (admission == StreamingWorkAdmission.Yielded) - break; - try { - ApplyResult(work.Result); + LandblockPublicationAdvance advance = ApplyResult( + work, + meter, + ensureProgress: !executed); + executed |= advance.Progressed; + if (!advance.Completed) + break; _completionQueue.RemoveHead(work); - meter.Complete(); - executed = true; } catch { @@ -1512,7 +1514,6 @@ public sealed class StreamingController : IStreamingFrameBackend // receipt cannot safely replay, so consume only that result. if (!_presentation.HasPendingPublication(work.Result)) _completionQueue.RemoveHead(work); - meter.Fail(); throw; } } @@ -1625,39 +1626,24 @@ public sealed class StreamingController : IStreamingFrameBackend }; } - private bool TryObserveResultOperation( - LandblockStreamResult result, + private static LandblockPublicationAdvance RunSimpleResultOperation( + StreamingWorkMeter meter, string stage, - Action operation, - StreamingWorkCost? knownCost = null, - bool ensureProgress = false) + bool ensureProgress, + Action operation) { - StreamingWorkMeter? meter = _activeWorkMeter; - if (meter is null) - { - operation(); - return true; - } - - StreamingWorkCost aggregate = knownCost - ?? LandblockStreamResultCost.Estimate(result).Work; - StreamingWorkCost cost = new( - EntityOperations: aggregate.EntityOperations, - GpuUploadBytes: aggregate.GpuUploadBytes, - GlRetireOperations: - result is LandblockStreamResult.Unloaded ? 1 : 0); StreamingWorkAdmission admission = meter.TryReserve( - cost, + default, stage, ensureProgress); if (admission == StreamingWorkAdmission.Yielded) - return false; + return new LandblockPublicationAdvance(false, false); try { operation(); meter.Complete(); - return true; + return new LandblockPublicationAdvance(true, true); } catch { @@ -1671,13 +1657,22 @@ public sealed class StreamingController : IStreamingFrameBackend /// effects: terrain upload, GPU state, and the re-hydration callback. /// All priority queues route through this one publication path. /// - private void ApplyResult(LandblockStreamResult result) + private LandblockPublicationAdvance ApplyResult( + StreamingQueuedCompletion work, + StreamingWorkMeter meter, + bool ensureProgress) { + LandblockStreamResult result = work.Result; if (_presentation.HasPendingPublication(result)) { - _presentation.ResumePublication(result); - ReconcileCompletedPendingPublication(result.LandblockId); - return; + LandblockPublicationAdvance resumed = + _presentation.ResumePublication( + result, + meter, + ensureProgress); + if (resumed.Completed) + ReconcileCompletedPendingPublication(result.LandblockId); + return resumed; } if (IsStaleGeneration(result)) @@ -1685,7 +1680,11 @@ public sealed class StreamingController : IStreamingFrameBackend // A worker can finish one old load/unload after a hard recenter. // Landblock id membership cannot distinguish overlapping windows; // generation is the logical streaming incarnation boundary. - return; + return RunSimpleResultOperation( + meter, + "execute-stale-generation", + ensureProgress, + static () => { }); } if (result is LandblockStreamResult.Unloaded @@ -1695,7 +1694,11 @@ public sealed class StreamingController : IStreamingFrameBackend // rapid away->back can therefore re-own an id while its same- // generation unload is already in the worker outbox. Current // region ownership wins; the old unload must not tear it down. - return; + return RunSimpleResultOperation( + meter, + "execute-reowned-unload", + ensureProgress, + static () => { }); } if (result is LandblockStreamResult.Loaded @@ -1707,7 +1710,11 @@ public sealed class StreamingController : IStreamingFrameBackend // A hard recenter/collapse can leave one worker job already in // flight. Its completion belongs to the old region and must not // resurrect a landblock the new StreamingRegion never owns. - return; + return RunSimpleResultOperation( + meter, + "execute-undesired-load", + ensureProgress, + static () => { }); } bool isNearCompletion = result is LandblockStreamResult.Promoted @@ -1722,7 +1729,11 @@ public sealed class StreamingController : IStreamingFrameBackend // high-priority jobs. Publication is idempotent at this owner: // never append statics, replay defaults, or reapply the complete // Near transaction to an already-Near landblock. - return; + return RunSimpleResultOperation( + meter, + "execute-duplicate-near", + ensureProgress, + static () => { }); } if (desiredTier == LandblockStreamTier.Far && isNearCompletion) { @@ -1739,14 +1750,26 @@ public sealed class StreamingController : IStreamingFrameBackend switch (result) { case LandblockStreamResult.Loaded loaded: - _presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData); - break; + return _presentation.PublishAsFar( + loaded, + loaded.Build, + loaded.MeshData, + meter, + ensureProgress); case LandblockStreamResult.Promoted promoted: - _presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData); - break; + return _presentation.PublishAsFar( + promoted, + promoted.Build, + promoted.MeshData, + meter, + ensureProgress); } } - return; + return RunSimpleResultOperation( + meter, + "execute-already-far", + ensureProgress, + static () => { }); } } @@ -1759,31 +1782,54 @@ public sealed class StreamingController : IStreamingFrameBackend // This Far completion was queued before a newer Near load // or promotion. Applying it would erase the entity/cell // layer that now owns the landblock. - break; + return RunSimpleResultOperation( + meter, + "execute-stale-far-tier", + ensureProgress, + static () => { }); } - _presentation.PublishLoaded(loaded); - break; + return _presentation.PublishLoaded( + loaded, + work.Estimate, + meter, + ensureProgress); case LandblockStreamResult.Promoted promoted: // PromoteToNear carries a complete build and mesh because the // streamer deliberately lets it supersede a queued LoadFar. If // that Far job never started, publish this as the real Near // landblock; if the base is already resident, merge only the // Near layer so existing live projections retain identity. - _presentation.PublishPromoted( + return _presentation.PublishPromoted( promoted, - mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId)); - break; + mergeIntoExistingLandblock: + _state.IsLoaded(promoted.LandblockId), + work.Estimate, + meter, + ensureProgress); case LandblockStreamResult.Unloaded unloaded: - _presentation.EnqueueFullRetirement(unloaded.LandblockId); - break; + return RunSimpleResultOperation( + meter, + "execute-unload", + ensureProgress, + () => _presentation.EnqueueFullRetirement( + unloaded.LandblockId)); case LandblockStreamResult.Failed failed: - Console.WriteLine( - $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); - break; + return RunSimpleResultOperation( + meter, + "execute-load-failure", + ensureProgress, + () => Console.WriteLine( + $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}")); case LandblockStreamResult.WorkerCrashed crashed: - Console.WriteLine( - $"streaming: worker CRASHED: {crashed.Error}"); - break; + return RunSimpleResultOperation( + meter, + "execute-worker-crash", + ensureProgress, + () => Console.WriteLine( + $"streaming: worker CRASHED: {crashed.Error}")); + default: + throw new InvalidOperationException( + $"Unsupported streaming result {result.GetType().Name}."); } } diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index bafdbdd7..ecb3c08c 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -455,6 +455,18 @@ public sealed class ShadowObjectRegistry /// cells hydrated after a server spawn landed. /// public void RefloodLandblock(uint landblockId) + { + uint[] owners = CaptureRefloodOwnersForLandblock(landblockId); + for (int i = 0; i < owners.Length; i++) + RefloodOwnerForLandblock(owners[i], landblockId); + } + + /// + /// Captures the stable ordered owner set touched by one landblock reflood. + /// App-layer streaming can retain this receipt and advance one owner per + /// frame without changing the collision registry's ownership rules. + /// + public uint[] CaptureRefloodOwnersForLandblock(uint landblockId) { uint lbPrefix = landblockId & 0xFFFF0000u; var toReflood = new HashSet(); @@ -487,39 +499,81 @@ public sealed class ShadowObjectRegistry } } - foreach (uint entityId in toReflood) + uint[] ordered = toReflood.ToArray(); + Array.Sort(ordered); + return ordered; + } + + /// + /// Re-runs one owner from a retained landblock-reflood receipt. A removed, + /// suspended, or otherwise superseded owner is an idempotent no-op. + /// + public void RefloodOwnerForLandblock(uint entityId, uint landblockId) + { + uint lbPrefix = landblockId & 0xFFFF0000u; + if (_suspendedEntities.Contains(entityId) + || !_entityReg.TryGetValue( + entityId, + out RegistrationRecord? reg)) { - var reg = _entityReg[entityId]; - _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood); - if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes)) - { - RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes, - reg.State, reg.Flags, 0f, 0f, lbPrefix, - reg.SeedCellId, reg.IsStatic); - } - else - { - Register(entityId, reg.GfxObjId, reg.EntityWorldPos, reg.EntityWorldRot, - reg.Radius, 0f, 0f, lbPrefix, - reg.CollisionType, reg.CylHeight, reg.Scale, - reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic); - } + return; + } - // Register is also the authoritative movement/replacement API and - // therefore clears obsolete markers. Only this streaming reflood - // operation preserves the still-missing prefixes across replacement. - if (withdrawnBeforeReflood is not null) - _withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood; + _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out var withdrawnBeforeReflood); + if (reg.IsMultiPart + && _entityShapes.TryGetValue(entityId, out var shapes)) + { + RegisterMultiPart( + entityId, + reg.EntityWorldPos, + reg.EntityWorldRot, + shapes, + reg.State, + reg.Flags, + 0f, + 0f, + lbPrefix, + reg.SeedCellId, + reg.IsStatic); + } + else + { + Register( + entityId, + reg.GfxObjId, + reg.EntityWorldPos, + reg.EntityWorldRot, + reg.Radius, + 0f, + 0f, + lbPrefix, + reg.CollisionType, + reg.CylHeight, + reg.Scale, + reg.State, + reg.Flags, + reg.SeedCellId, + reg.IsStatic); + } - if (_entityToCells.TryGetValue(entityId, out var refreshedCells) - && refreshedCells.Exists(cell => - (cell & 0xFFFF0000u) == lbPrefix) - && _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn)) - { - withdrawn.Remove(lbPrefix); - if (withdrawn.Count == 0) - _withdrawnPrefixesByOwner.Remove(entityId); - } + // Register is also the authoritative movement/replacement API and + // therefore clears obsolete markers. Only this streaming reflood + // operation preserves the still-missing prefixes across replacement. + if (withdrawnBeforeReflood is not null) + _withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood; + + if (_entityToCells.TryGetValue(entityId, out var refreshedCells) + && refreshedCells.Exists(cell => + (cell & 0xFFFF0000u) == lbPrefix) + && _withdrawnPrefixesByOwner.TryGetValue( + entityId, + out var withdrawn)) + { + withdrawn.Remove(lbPrefix); + if (withdrawn.Count == 0) + _withdrawnPrefixesByOwner.Remove(entityId); } } @@ -596,6 +650,17 @@ public sealed class ShadowObjectRegistry /// are deliberately retained for spatial reflood after streaming changes. /// public void DeregisterStaticOwnersForLandblock(uint landblockId) + { + uint[] owners = CaptureStaticOwnersForLandblock(landblockId); + for (int i = 0; i < owners.Length; i++) + DeregisterStaticOwnerForLandblock(owners[i], landblockId); + } + + /// + /// Captures a stable ordered receipt for static collision owners rooted in + /// one landblock. + /// + public uint[] CaptureStaticOwnersForLandblock(uint landblockId) { uint prefix = landblockId & 0xFFFF0000u; var owners = new List(); @@ -607,9 +672,27 @@ public sealed class ShadowObjectRegistry owners.Add(entityId); } } + owners.Sort(); + return owners.ToArray(); + } - foreach (uint entityId in owners) + /// + /// Removes one static owner from a retained landblock receipt. If the + /// owner was already removed or rebound elsewhere, the operation no-ops. + /// + public void DeregisterStaticOwnerForLandblock( + uint entityId, + uint landblockId) + { + uint prefix = landblockId & 0xFFFF0000u; + if (_entityReg.TryGetValue( + entityId, + out RegistrationRecord? registration) + && registration.IsStatic + && (registration.SeedCellId & 0xFFFF0000u) == prefix) + { Deregister(entityId); + } } /// diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs index e3084493..e5afceeb 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Numerics; +using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -86,6 +87,43 @@ public class BuildingLoaderTests Assert.Equal(new SortedSet { 1, 2 }, ids); // sequential 1, 2 } + [Fact] + public void RetainedPublication_AdvancesOneBuildingAndCommitsCellStampsAtomically() + { + LandBlockInfo info = MakeInfo( + (0x02000001u, new[] { 0x0150u }), + (0x02000002u, new[] { 0x0160u })); + LoadedCell cell150 = MinimalCell(0xA9B40150u); + LoadedCell cell160 = MinimalCell(0xA9B40160u); + var cells = new Dictionary + { + [cell150.CellId] = cell150, + [cell160.CellId] = cell160, + }; + + BuildingRegistryPublication publication = + BuildingLoader.PreparePublication(info, 0xA9B40000u, cells); + + Assert.False(publication.PreparationCommitted); + Assert.False(BuildingLoader.AdvancePreparationOne(publication)); + Assert.Equal(1, publication.BuildingCursor); + Assert.Equal(1, publication.Registry.Count); + Assert.Null(cell150.BuildingId); + Assert.Null(cell160.BuildingId); + + Assert.True(BuildingLoader.AdvancePreparationOne(publication)); + Assert.Equal(2, publication.BuildingCursor); + Assert.Equal(2, publication.Registry.Count); + Assert.Null(cell150.BuildingId); + Assert.Null(cell160.BuildingId); + + BuildingLoader.CommitPublication(publication); + + Assert.Equal(1u, cell150.BuildingId); + Assert.Equal(2u, cell160.BuildingId); + Assert.True(publication.PublicationCommitted); + } + [Fact] public void Build_StampsLoadedCellBuildingId() { @@ -166,4 +204,16 @@ public class BuildingLoaderTests Assert.Equal(new Vector3(9, 12, 33), b.PortalBounds.Min); Assert.Equal(new Vector3(17, 25, 39), b.PortalBounds.Max); } + + private static LoadedCell MinimalCell(uint cellId) => new() + { + CellId = cellId, + Portals = new List(), + PortalPolygons = new List(), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + LocalBoundsMin = new Vector3(-5, -5, -5), + LocalBoundsMax = new Vector3(5, 5, 5), + ClipPlanes = new List(), + }; } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index fa7f87a2..dc9da3ed 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -56,6 +56,73 @@ public sealed class LandblockConcretePresentationPipelineTests Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); } + [Fact] + public void MeteredLoaded_RetainsExactHeadAndPublishesSpatiallyOnlyAfterOwnerSuffix() + { + var calls = new List(); + ConcreteFixture fixture = Fixture( + calls, + commitEnvCells: _ => calls.Add("envcell")); + fixture.Events.EntitySpawned += _ => calls.Add("plugin"); + var pipeline = new LandblockPresentationPipeline( + fixture.Render, + fixture.Physics, + fixture.Static, + fixture.State, + fixture.RetirementOwner, + onLandblockLoaded: _ => calls.Add("live-recovery")); + LandblockStreamResult.Loaded result = + Result(Build(Entity(0x80A9B401u), Entity(0x80A9B402u))); + LandblockStreamCostEstimate estimate = + LandblockStreamResultCost.Estimate(result); + var budget = new StreamingWorkBudget( + TimeSpan.FromSeconds(1), + maxCompletionAdmissions: 64, + maxAdoptedCpuBytes: 1_000_000, + maxEntityOperations: 1, + maxGpuUploadBytes: 1_000_000, + maxGlRetireOperations: 64, + destinationReserveFraction: 0.75f); + + LandblockPublicationAdvance advance = default; + for (int frame = 0; frame < 64; frame++) + { + var meter = new StreamingWorkMeter(budget); + advance = frame == 0 + ? pipeline.PublishLoaded( + result, + estimate, + meter, + ensureProgress: false) + : pipeline.ResumePublication( + result, + meter, + ensureProgress: false); + meter.FinishFrame(); + + Assert.True( + meter.Snapshot.Used.EntityOperations <= 1, + $"stage={meter.Snapshot.LastStage}, " + + $"entities={meter.Snapshot.Used.EntityOperations}, " + + $"oversized={meter.Snapshot.OversizedProgressCount}"); + if (fixture.Static.Diagnostics.CompleteCount == 0) + Assert.False(fixture.State.IsLoaded(LandblockId)); + if (advance.Completed) + break; + } + + Assert.True(advance.Completed); + Assert.False(pipeline.HasPendingPublication(result)); + Assert.True(fixture.State.IsNearTier(LandblockId)); + Assert.Equal( + ["terrain", "envcell", "plugin", "plugin", "pin", "live-recovery"], + calls); + Assert.Equal(1, fixture.Render.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount); + Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount); + Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount); + } + [Fact] public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes() { diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index d78ab1c8..a230e70d 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -1119,7 +1119,15 @@ public sealed class LandblockPresentationPipelineTests failReplayOnce = false; throw new InvalidOperationException("injected replay failure"); } - }); + }, + workBudgetOptions: new StreamingWorkBudgetOptions( + MaxUpdateMilliseconds: 100, + MaxCompletionAdmissions: 64, + MaxAdoptedCpuBytes: 1_000_000, + MaxEntityOperations: 1_000, + MaxGpuUploadBytes: 1_000_000, + MaxGlRetireOperations: 1_000, + DestinationReserveFraction: 0.75f)); Assert.Throws(() => controller.Tick(0x90, 0x90)); Assert.True(state.IsNearTier(landblockId)); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs index 5bf84ba2..bf7a6309 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRenderPublisherTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using System.Reflection; +using System.Collections.Immutable; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; using AcDream.App.Streaming; @@ -194,6 +195,47 @@ public sealed class LandblockRenderPublisherTests Assert.Equal(1, first.Diagnostics.CompleteCount); } + [Fact] + public void RetainedEnvCellPublisher_AdvancesOneShellBeforeAtomicCommit() + { + var envPublisher = new RecordingEnvCellPublisher(); + EnvCellLandblockBuild envCells = new( + LandblockId, + Array.Empty(), + [ + Shell(0xA9B40100u, 1), + Shell(0xA9B40101u, 2), + Shell(0xA9B40102u, 3), + ]); + var publisher = new LandblockRenderPublisher( + publishTerrain: static (_, _, _) => { }, + removeTerrain: static _ => { }, + cellVisibility: new CellVisibility(), + worldState: new GpuWorldState(), + envCellPublisher: envPublisher); + LandblockRenderPublication receipt = publisher.PreparePublication( + Build(envCells), + EmptyMesh()); + publisher.BeginPublication(receipt); + + Assert.False(publisher.AdvanceCompleteOne(receipt)); + Assert.Equal(0, envPublisher.AdvancedShells); + Assert.Equal(0, envPublisher.CommitCount); + for (int shell = 1; shell <= 3; shell++) + { + Assert.False(publisher.AdvanceCompleteOne(receipt)); + Assert.Equal(shell, envPublisher.AdvancedShells); + Assert.Equal(0, envPublisher.CommitCount); + } + + Assert.False(publisher.AdvanceCompleteOne(receipt)); + Assert.Equal(0, envPublisher.CommitCount); + Assert.False(publisher.AdvanceCompleteOne(receipt)); + Assert.Equal(1, envPublisher.CommitCount); + Assert.True(publisher.AdvanceCompleteOne(receipt)); + Assert.Equal(1, publisher.Diagnostics.CompleteCount); + } + [Fact] public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary() { @@ -341,6 +383,19 @@ public sealed class LandblockRenderPublisherTests InverseWorldTransform = Matrix4x4.Identity, }; + private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId) => + new( + cellId, + geometryId, + EnvironmentId: 1u, + CellStructure: 0, + Surfaces: ImmutableArray.Empty, + WorldPosition: Vector3.Zero, + Rotation: Quaternion.Identity, + Transform: Matrix4x4.Identity, + LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One), + WorldBounds: new WbBoundingBox(Vector3.Zero, Vector3.One)); + private static LandblockMeshData MeshAtHeights(float first, float second) => new( [ new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0), @@ -362,4 +417,42 @@ public sealed class LandblockRenderPublisherTests } throw new DirectoryNotFoundException("Could not locate repository root."); } + + private sealed class RecordingEnvCellPublisher : + IEnvCellLandblockPublisher + { + private readonly object _owner = new(); + + public int AdvancedShells { get; private set; } + public int CommitCount { get; private set; } + + public EnvCellLandblockPublication PreparePublication( + EnvCellLandblockBuild build) => + new(_owner, build); + + public bool AdvancePreparationOne( + EnvCellLandblockPublication publication) + { + Assert.Same(_owner, publication.Owner); + if (publication.PreparationCommitted) + return true; + if (publication.ShellCursor < publication.Build.Shells.Length) + { + publication.ShellCursor++; + AdvancedShells++; + return false; + } + + publication.PreparationCommitted = true; + return true; + } + + public void CommitPublication( + EnvCellLandblockPublication publication) + { + Assert.True(publication.PreparationCommitted); + publication.PublicationCommitted = true; + CommitCount++; + } + } } diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs index d58fc92e..4043a423 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs @@ -507,7 +507,16 @@ public sealed class LandblockRetirementCoordinatorTests nearRadius: 0, farRadius: 0, retirementCoordinator: coordinator, - workBudgetOptions: workBudgetOptions); + workBudgetOptions: workBudgetOptions ?? GenerousWorkBudget()); + + private static StreamingWorkBudgetOptions GenerousWorkBudget() => new( + MaxUpdateMilliseconds: 100, + MaxCompletionAdmissions: 64, + MaxAdoptedCpuBytes: 64 * StreamingWorkBudgetOptions.MiB, + MaxEntityOperations: 1_024, + MaxGpuUploadBytes: 64 * StreamingWorkBudgetOptions.MiB, + MaxGlRetireOperations: 256, + DestinationReserveFraction: 0.75f); private static IReadOnlyList Drain( Queue pending, diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs index 5a1bf804..9c4c2257 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -238,7 +238,8 @@ public sealed class StreamingControllerReadinessTests ensureCalls++; }); - controller.Tick(0x12, 0x36); + for (int frame = 0; frame < 32 && ensureCalls == 0; frame++) + controller.Tick(0x12, 0x36); Assert.Equal(1, ensureCalls); } @@ -294,7 +295,8 @@ public sealed class StreamingControllerReadinessTests ensureCalls++; }); - controller.Tick(0x12, 0x36); + for (int frame = 0; frame < 32 && ensureCalls == 0; frame++) + controller.Tick(0x12, 0x36); Assert.Equal(1, ensureCalls); Assert.Equal(1, meshes.ReferenceCounts[geometryId]); diff --git a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs index a56c88f8..9df94774 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingFrameControllerTests.cs @@ -337,7 +337,7 @@ public sealed class StreamingFrameControllerTests } [Fact] - public void StreamingPublicationMakesSameFrameInboundProjectionResident() + public void CompletedStreamingPublicationMakesInboundProjectionResident() { const uint landblock = 0x0A14FFFFu; const uint cell = 0x0A140001u; @@ -359,7 +359,8 @@ public sealed class StreamingFrameControllerTests farRadius: 0); var fixture = new Fixture(streamingBackend: streaming); - fixture.Controller.Tick(); + for (int frame = 0; frame < 32 && !state.IsLoaded(landblock); frame++) + fixture.Controller.Tick(); Assert.True(state.IsLoaded(landblock)); var runtime = Runtime(state); diff --git a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs index 00128aae..2b0ddc74 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs @@ -347,8 +347,8 @@ public sealed class StreamingWorkBudgetTests Assert.Equal(1, first.DeferredCompletions); Assert.Equal(44, first.DeferredAdoptedCpuBytes); Assert.True(first.OldestDeferredAgeMilliseconds >= 0); - Assert.Equal(3, first.LastFrame.Operations); - Assert.Equal(3, first.LastFrame.CompletedOperations); + Assert.Equal(6, first.LastFrame.Operations); + Assert.Equal(6, first.LastFrame.CompletedOperations); Assert.Equal(1, first.LastFrame.YieldCount); Assert.Equal(88, first.LastFrame.Used.AdoptedCpuBytes); @@ -358,7 +358,7 @@ public sealed class StreamingWorkBudgetTests Assert.Equal(0, second.DeferredCompletions); Assert.Equal(0, second.DeferredAdoptedCpuBytes); Assert.Equal(0, second.OldestDeferredAgeMilliseconds); - Assert.Equal(1, second.LastFrame.CompletedOperations); + Assert.Equal(4, second.LastFrame.CompletedOperations); } [Fact] @@ -409,7 +409,7 @@ public sealed class StreamingWorkBudgetTests } [Fact] - public void DestinationAndUnloadPriorityNeverBypassExecutionBudgets() + public void DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget() { uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32); uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33); @@ -432,16 +432,16 @@ public sealed class StreamingWorkBudgetTests controller.Tick(32, 32); Assert.Equal([center], applied); - Assert.Equal(2, controller.WorkDiagnostics.DeferredCompletions); + Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions); Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount); Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes); - Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); + Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); controller.Tick(32, 32); Assert.Equal([center, ordinary], applied); Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); - Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); + Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index b42045d2..91194f05 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -330,6 +330,65 @@ public class ShadowObjectRegistryTests entry => entry.EntityId == entityId); } + [Fact] + public void StreamingReceipts_AreStableOrderedAndAdvanceOneStaticOwner() + { + var reg = new ShadowObjectRegistry(); + reg.Register( + 9u, + 0x01000009u, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: true); + reg.Register( + 2u, + 0x01000002u, + new Vector3(14f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: true); + reg.Register( + 5u, + 0x01000005u, + new Vector3(16f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + + Assert.Equal( + [2u, 9u], + reg.CaptureStaticOwnersForLandblock(LbId)); + Assert.Equal( + [2u, 5u, 9u], + reg.CaptureRefloodOwnersForLandblock(LbId)); + + reg.DeregisterStaticOwnerForLandblock(2u, LbId); + + Assert.Equal(2, reg.RetainedRegistrationCount); + Assert.DoesNotContain( + reg.GetObjectsInCell(LbId | 1u), + entry => entry.EntityId == 2u); + Assert.Contains( + reg.GetObjectsInCell(LbId | 1u), + entry => entry.EntityId == 5u); + Assert.Contains( + reg.GetObjectsInCell(LbId | 1u), + entry => entry.EntityId == 9u); + } + [Fact] public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload() { diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs index b3d946b9..635e7cf3 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs @@ -372,7 +372,8 @@ public class StreamingControllerTests System.Array.Empty()); fake.Pending.Enqueue(new LandblockStreamResult.Loaded(landblockId, LandblockStreamTier.Near, lb, stubMesh)); - controller.Tick(50, 50); + for (int frame = 0; frame < 32 && !state.IsLoaded(landblockId); frame++) + controller.Tick(50, 50); Assert.Single(applied); Assert.True(state.IsLoaded(landblockId)); diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs index 4afb623c..d6c92889 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs @@ -136,7 +136,15 @@ public class StreamingControllerTwoTierTests }, state: state, nearRadius: 2, - farRadius: 2); + farRadius: 2, + workBudgetOptions: new StreamingWorkBudgetOptions( + MaxUpdateMilliseconds: 100, + MaxCompletionAdmissions: 64, + MaxAdoptedCpuBytes: 16 * StreamingWorkBudgetOptions.MiB, + MaxEntityOperations: 512, + MaxGpuUploadBytes: 16 * StreamingWorkBudgetOptions.MiB, + MaxGlRetireOperations: 128, + DestinationReserveFraction: 0.75f)); ctrl.Tick(50, 50); // drains the Promoted result