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