diff --git a/AGENTS.md b/AGENTS.md
index bdbd81fa..a845fb47 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -118,7 +118,8 @@ disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal formula-media/foreground correction passes
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
-**Current work order:** the six-slice pre-M4 world-interaction completion
+**M4 feature work order (paused while the authorized performance program
+executes):** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
@@ -130,17 +131,23 @@ user-accepted. Favorite-spell press-time selection and right-click local
examination are implemented; the current visual gate is its corrected modern
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
-**Active performance side track:** Slices A–C of
-`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
+**Current active engineering work:** complete Slices D–L of
+`docs/plans/2026-07-24-modern-runtime-architecture.md` under the recorded
+approval interlocks. Slices A–D completed 2026-07-24.
Production world-mesh streaming now consumes the validated machine-local
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
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 (typed
-handles and unified residency accounting) is next; ECS/MP3 remains behind its
-explicit user decision gate. Evidence:
-`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
+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
+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
+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
+`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@@ -161,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 D+, and #225's
+R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, 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 0ea9a8ec..733702c7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -116,7 +116,8 @@ disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal formula-media/foreground correction passes
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
-**Current work order:** the six-slice pre-M4 world-interaction completion
+**M4 feature work order (paused while the authorized performance program
+executes):** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
@@ -128,17 +129,23 @@ user-accepted. Favorite-spell press-time selection and right-click local
examination are implemented; the current visual gate is its corrected modern
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
-**Active performance side track:** Slices A–C of
-`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
+**Current active engineering work:** complete Slices D–L of
+`docs/plans/2026-07-24-modern-runtime-architecture.md` under the recorded
+approval interlocks. Slices A–D completed 2026-07-24.
Production world-mesh streaming now consumes the validated machine-local
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
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 (typed
-handles and unified residency accounting) is next; ECS/MP3 remains behind its
-explicit user decision gate. Evidence:
-`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
+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
+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
+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
+`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@@ -159,7 +166,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 D+, and #225's
+R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, 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 d3bb4199..c9f7c2ae 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -44,9 +44,11 @@ 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:** prepared-content Slices A–C and unified-residency
- Slice D are complete. Slice E cost-budgeted streaming/retirement is next.
- Evidence:
+- **Performance side track:** Slices A–D and Slice E0–E3 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:
[`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 a1dc787f..1a2037be 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,16 +8,20 @@ banner cannot silently override the current program state.
- **Milestone state:** M3, “Cast a spell,” landed 2026-07-21. M4, “Live in the
world,” is active.
-- **Active gameplay program:** the pre-M4 world-interaction completion program
+- **M4 gameplay program (paused while the authorized performance program
+ executes):** the pre-M4 world-interaction completion program
in
[`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.
-- **Active performance side track:** Slices A–C of
+- **Current active engineering program:** Slices A–D and Slice E0–E3 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; Slice D unified residency/accounting is next. See the
- [Slice C physical cutover report](research/2026-07-24-slice-c-prepared-asset-cutover-report.md).
+ 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
+ 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`).
- **Separate visual verification:** the shared-alpha lifestone/particle result
@@ -28,7 +32,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,076 tests pass and
+- **Latest automated baseline:** Release build succeeds; 8,148 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 e60a036c..9cf54e4f 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -720,8 +720,19 @@ exact reference identity and retry position, rejects stale generations, and
executes every class through the same meter. Priority changes order, never the
budget. Lifecycle artifacts publish worker and per-class backlog, retained
bytes/age, yields, oversizes, overruns, and pending publication/retirement
-facts. Publication remains transaction-atomic until Slice E4; retirement
-quiesce/cursors belong to E3.
+facts.
+
+At a hard login/portal reveal edge, `WorldGenerationQuiescence` makes the old
+generation unavailable through the same `WorldRevealCoordinator` lifetime.
+World rendering and spatial queries return no old content; object/effect clocks,
+liveness, and spatial reconciliation stop; world-space audio is silenced; UI,
+network/event/command delivery, portal presentation, destination streaming, and
+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.
### World-reveal readiness ownership
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3d94b544..5172fd1f 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. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.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 an empty 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, or missing scenery; 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. 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 b2f2e2bb..48fb59ea 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1605,13 +1605,16 @@ port in any phase — no separate listing here.
> and dense-Arwic physical routes passed; third-visit cache residence was
> non-growing and no checkpoint retained staged or retiring bytes. [Residency
> report](../research/2026-07-24-slice-d-unified-residency-report.md). Slice E
-> cost-budgeted streaming/retirement is active. E2 has replaced the flat
+> cost-budgeted streaming/retirement is active. E2 replaced the flat
> deferred list, count-only throttle, priority hunt, and unload bypass with one
> typed-meter scheduler over explicit stable destination/control/unload/Near/
> Far queues; production prices worker results before adoption and lifecycle
-> artifacts expose exact backlog/retained-byte facts. E3 next adds immediate
-> old-generation quiesce and cursored retirement. F/G and J retain their
-> explicit approval gates.
+> artifacts expose exact backlog/retained-byte facts. E3 now makes the old
+> 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
+> gates.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index a058c9d4..4560adf2 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -93,10 +93,12 @@ 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–E2 now define and enforce typed completion admission through explicit
-stable priority queues; E3 adds immediate old-generation quiesce and cursored
-retirement next. Slices F/G remain behind their explicit ECS approval gate,
-and Slice J remains behind its gameplay-owner approval gate.
+Slice E0–E3 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.
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 dcd9f11a..d35fd05f 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/E1 complete).
+**Status:** Slices A–D complete. Slice E is active (E0–E3 complete).
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
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 964dc2b8..3a958937 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 complete; E3 quiesce and budgeted retirement next
+**Status:** active — E0/E1/E2/E3 complete; E4 cursor-budgeted publication 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
@@ -288,6 +288,35 @@ E6; the active desktop was RDP and is not valid performance evidence.
- Prove no old generation ticks, emits, collides, targets, or renders while its
retained memory converges.
+**Complete 2026-07-24.** `WorldGenerationQuiescence` now publishes the hard
+login/portal edge through one generation-scoped availability state owned by
+`WorldRevealCoordinator`. The edge withdraws world drawing, picking/radar
+spatial queries, target selection, object/static animation, PhysicsScript time,
+particle/effect progression, liveness, spatial reconciliation, and owner-tagged
+3-D audio while network/event/command, portal presentation, UI, streaming, and
+destination readiness continue. A superseding reveal remains continuously
+quiescent and only its exact generation can reopen the world; canonical live
+records and readiness state remain retained, so the mechanism does not
+synthesize Hidden/UnHide or rebuild server identity.
+
+Full-window and shared-origin retirement are now retained frame transactions.
+Generation invalidation, worker-inbox clearing, accepted-payload release,
+region clearing, stable resident capture, and landblock detachment all resume
+from exact cursors through the frame's single `StreamingWorkMeter`.
+`LandblockRetirementCoordinator` preserves stable FIFO order and advances one
+entity or one owner stage per reservation across scripts, classifications,
+lighting, translucency, plugin projection, terrain, physics, cell visibility,
+building registries, and environment cells. Failures retry the exact unfinished
+entity/stage without replaying committed work. A landblock detach remains one
+named indivisible spatial boundary; its complete entity count is charged before
+the operation.
+
+Validation: 51 focused reveal/quiescence/retirement tests passed; the complete
+App suite passed 3,666 / 3 skipped; the complete solution passed 8,148 / 5
+skipped; Release solution build green. The physical connected performance and
+visual gate remains deferred to E6 because the active desktop is RDP and is not
+valid GPU/FPS evidence.
+
### E4 — Cursor-budgeted publication
- Move stable ordered arrays/cost facts into retained receipts.
diff --git a/src/AcDream.App/Audio/AudioHookSink.cs b/src/AcDream.App/Audio/AudioHookSink.cs
index 75cf59fe..a687fafc 100644
--- a/src/AcDream.App/Audio/AudioHookSink.cs
+++ b/src/AcDream.App/Audio/AudioHookSink.cs
@@ -122,7 +122,14 @@ public sealed class AudioHookSink : IAnimationHookSink
if (waveId == 0) return;
WaveData? wave = _cache.GetWave(waveId);
if (wave is null) return;
- _engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
+ _engine.Play3DWave(
+ entityId,
+ waveId,
+ wave,
+ worldPos,
+ volume,
+ priority,
+ pitch);
}
}
diff --git a/src/AcDream.App/Audio/OpenAlAudioEngine.cs b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
index eaa22ac4..ba4f249a 100644
--- a/src/AcDream.App/Audio/OpenAlAudioEngine.cs
+++ b/src/AcDream.App/Audio/OpenAlAudioEngine.cs
@@ -54,7 +54,13 @@ namespace AcDream.App.Audio;
/// unaffected.
///
///
-public sealed unsafe class OpenAlAudioEngine : IAudioEngine
+internal interface IWorldAudioQuiescence
+{
+ void SuspendWorldAudio();
+ void ResumeWorldAudio();
+}
+
+public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
{
// ── Backends ─────────────────────────────────────────────────────────────
private AL? _al;
@@ -71,12 +77,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
private sealed class Slot3D
{
public uint SourceId;
+ public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
public uint PriorityBase; // raw priority from SoundEntry.Priority
}
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
private int _pool3DCursor; // round-robin start
+ private bool _worldAudioSuspended;
private readonly uint[] _poolUi = new uint[PoolSizeUi];
@@ -238,6 +246,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
/// Returns true on success, false if the buffer was rejected.
///
public bool Play3DWave(
+ uint ownerId,
uint waveId,
WaveData wave,
Vector3 position,
@@ -245,7 +254,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
float priority,
float pitch = 1.0f)
{
- if (!_available || _al is null) return false;
+ if (_worldAudioSuspended || !_available || _al is null) return false;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
@@ -284,11 +293,39 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
slot.PlayingGain = effectiveGain;
slot.InUse = true;
+ slot.OwnerId = ownerId;
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
+ ///
+ /// Stops every world-space voice while preserving the independent UI
+ /// source pool. Retail suppresses ambient/object audio while cell loading
+ /// blocks world maintenance; stopped voices are not resumed afterward.
+ ///
+ public void SuspendWorldAudio()
+ {
+ _worldAudioSuspended = true;
+ for (int i = 0; i < _pool3D.Length; i++)
+ StopWorldSlot(_pool3D[i]);
+ }
+
+ public void ResumeWorldAudio() => _worldAudioSuspended = false;
+
+ internal void StopAllForOwner(uint ownerId)
+ {
+ if (ownerId == 0)
+ return;
+
+ for (int i = 0; i < _pool3D.Length; i++)
+ {
+ Slot3D slot = _pool3D[i];
+ if (slot.InUse && slot.OwnerId == ownerId)
+ StopWorldSlot(slot);
+ }
+ }
+
///
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
@@ -469,4 +506,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
return state == (int)SourceState.Playing;
}
+
+ private void StopWorldSlot(Slot3D slot)
+ {
+ if (_available && _al is not null && slot.SourceId != 0)
+ {
+ _al.SourceStop(slot.SourceId);
+ _al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
+ }
+
+ slot.OwnerId = 0;
+ slot.PlayingGain = 0f;
+ slot.PriorityBase = 0;
+ slot.InUse = false;
+ }
}
diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 66b8db5e..034d063e 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -384,7 +384,8 @@ internal sealed class FrameRootCompositionPhase
retailPViewCells,
worldScenePasses,
d.RenderRange,
- worldSceneDiagnostics);
+ worldSceneDiagnostics,
+ live.WorldAvailability);
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
@@ -469,7 +470,8 @@ internal sealed class FrameRootCompositionPhase
live.WorldState,
session.LiveSession,
session.LocalPlayerFrame,
- session.LiveSpatialReconciler);
+ session.LiveSpatialReconciler,
+ live.WorldAvailability);
var cameraFrame = new CameraFrameController(
host.CameraController,
d.InputCapture,
@@ -496,7 +498,8 @@ internal sealed class FrameRootCompositionPhase
new StopwatchClientMonotonicTimeSource()),
session.LocalTeleport,
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
- cameraFrame);
+ cameraFrame,
+ live.WorldAvailability);
Fault(FrameRootCompositionPoint.UpdateRootCreated);
var bindingsLease = scope.Own(
diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs
index e7551405..6f3cf9cb 100644
--- a/src/AcDream.App/Composition/LivePresentationComposition.cs
+++ b/src/AcDream.App/Composition/LivePresentationComposition.cs
@@ -78,6 +78,7 @@ internal sealed record LivePresentationResult(
EntitySpawnAdapter EntitySpawnAdapter,
EntityScriptActivator EntityScriptActivator,
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
+ WorldGenerationAvailabilityState WorldAvailability,
GpuWorldState WorldState,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
@@ -319,10 +320,12 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler.Unregister,
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
+ var worldAvailability = new WorldGenerationAvailabilityState();
var worldState = new GpuWorldState(
wbSpawnAdapter,
d.ClassificationCache.InvalidateLandblock,
- entityScriptActivator);
+ entityScriptActivator,
+ worldAvailability);
bindings = new LivePresentationRuntimeBindings();
if (d.DevWorldEntities is { } devWorldEntities)
{
@@ -524,6 +527,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
+ worldAvailability,
worldState,
liveEntities,
projectileController,
@@ -568,6 +572,7 @@ internal sealed class LivePresentationCompositionPhase
EntitySpawnAdapter entitySpawnAdapter,
EntityScriptActivator entityScriptActivator,
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
+ WorldGenerationAvailabilityState worldAvailability,
GpuWorldState worldState,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
@@ -942,6 +947,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
+ worldAvailability,
worldState,
liveEntities,
projectileController,
diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs
index f240d398..64a1f4c8 100644
--- a/src/AcDream.App/Composition/SessionPlayerComposition.cs
+++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs
@@ -321,6 +321,11 @@ internal sealed class SessionPlayerCompositionPhase
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
content.Dats,
d.DatLock);
+ var worldQuiescence = new WorldGenerationQuiescence(
+ live.WorldAvailability,
+ d.Selection,
+ live.WorldState,
+ content.Audio?.Engine);
var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady,
@@ -333,7 +338,8 @@ internal sealed class SessionPlayerCompositionPhase
radius),
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
spawnClaimClassifier.IsUnhydratable,
- d.Log);
+ d.Log,
+ worldQuiescence);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(
diff --git a/src/AcDream.App/Rendering/WorldSceneRenderer.cs b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
index 0da63a0e..cd901d23 100644
--- a/src/AcDream.App/Rendering/WorldSceneRenderer.cs
+++ b/src/AcDream.App/Rendering/WorldSceneRenderer.cs
@@ -1,5 +1,6 @@
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
+using AcDream.App.Streaming;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
@@ -59,6 +60,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldScenePassExecutor _passes;
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
+ private readonly IWorldGenerationAvailability _availability;
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
@@ -73,7 +75,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
IRetailPViewCellSource pviewCells,
IWorldScenePassExecutor passes,
IWorldRenderRangeSource renderRange,
- IWorldSceneDiagnostics diagnostics)
+ IWorldSceneDiagnostics diagnostics,
+ IWorldGenerationAvailability? availability = null)
{
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
_login = login ?? throw new ArgumentNullException(nameof(login));
@@ -89,6 +92,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
+ _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@@ -100,6 +104,13 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
try
{
_selection?.BeginFrame();
+ if (!_availability.IsWorldAvailable)
+ {
+ _selection?.CompleteFrame();
+ selectionFrameStarted = false;
+ return default;
+ }
+
RenderFrameFoundation foundation = _foundation.Foundation;
if (foundation.PortalViewportVisible)
{
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 3a96f5d5..519d8780 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -56,6 +56,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
+ private readonly IWorldGenerationAvailability _availability;
///
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
@@ -72,11 +73,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
System.Action? onLandblockUnloaded = null,
- EntityScriptActivator? entityScriptActivator = null)
+ EntityScriptActivator? entityScriptActivator = null,
+ IWorldGenerationAvailability? availability = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
+ _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
private readonly Dictionary _loaded = new();
@@ -151,7 +154,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
public bool IsLiveEntityVisible(uint serverGuid) =>
- serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(serverGuid);
+ _availability.IsWorldAvailable
+ && serverGuid != 0
+ && _visibleLiveProjectionCounts.ContainsKey(serverGuid);
///
/// Try to grab the loaded record for a landblock — useful for callers
@@ -217,6 +222,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
get
{
+ if (!_availability.IsWorldAvailable)
+ yield break;
+
foreach (var kvp in _loaded)
{
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
@@ -232,6 +240,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
IReadOnlyDictionary? AnimatedById)> EnumerateLandblockEntries(
bool includeAnimatedIndex)
{
+ if (!_availability.IsWorldAvailable)
+ yield break;
+
foreach (var kvp in _loaded)
{
IReadOnlyDictionary? byId = null;
@@ -299,6 +310,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
destination.Clear();
+ if (!_availability.IsWorldAvailable)
+ return;
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
index 9139b85d..06c972b9 100644
--- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
+++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs
@@ -141,8 +141,9 @@ public sealed class LandblockPresentationPipeline
}
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
- _retirements = new LandblockRetirementCoordinator(
+ _retirements = LandblockRetirementCoordinator.CreateBudgeted(
state,
+ retirementOwner.AdvanceOne,
retirementOwner.Advance);
}
@@ -162,6 +163,7 @@ public sealed class LandblockPresentationPipeline
_staticPublisher?.Diagnostics ?? default);
public int PendingRetirementCount => _retirements.PendingCount;
+ internal bool UsesBudgetedRetirementSteps => _retirements.UsesBudgetedSteps;
public int PendingPublicationCount => _publications.Count;
internal bool MatchesState(GpuWorldState state) =>
@@ -189,10 +191,27 @@ public sealed class LandblockPresentationPipeline
public void AdvanceRetirements() => _retirements.Advance();
- public void BeginFullRetirement(uint landblockId) =>
+ public void AdvanceRetirements(StreamingWorkMeter meter) =>
+ _retirements.Advance(meter);
+
+ public void BeginFullRetirement(uint landblockId)
+ {
+ _retirements.BeginFull(landblockId);
+ if (_retirements.UsesBudgetedSteps)
+ _retirements.Advance();
+ }
+
+ public void BeginNearLayerRetirement(uint landblockId)
+ {
+ _retirements.BeginNearLayer(landblockId);
+ if (_retirements.UsesBudgetedSteps)
+ _retirements.Advance();
+ }
+
+ internal void EnqueueFullRetirement(uint landblockId) =>
_retirements.BeginFull(landblockId);
- public void BeginNearLayerRetirement(uint landblockId) =>
+ internal void EnqueueNearLayerRetirement(uint landblockId) =>
_retirements.BeginNearLayer(landblockId);
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
diff --git a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs
index 9c409891..cb1462ca 100644
--- a/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs
+++ b/src/AcDream.App/Streaming/LandblockPresentationRetirementOwner.cs
@@ -101,6 +101,58 @@ public sealed class LandblockPresentationRetirementOwner
() => _render.RemoveEnvironmentCells(ticket.LandblockId));
}
+ internal LandblockRetirementOperationResult AdvanceOne(
+ LandblockRetirementTicket ticket)
+ {
+ ArgumentNullException.ThrowIfNull(ticket);
+ bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
+ return ticket.NextIncompleteStage switch
+ {
+ LandblockRetirementStage.EntityLighting =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.EntityLighting,
+ entity => !staticOnly || entity.ServerGuid == 0,
+ RemoveLighting),
+ LandblockRetirementStage.EntityTranslucency =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.EntityTranslucency,
+ entity => !staticOnly || entity.ServerGuid == 0,
+ RemoveTranslucency),
+ LandblockRetirementStage.PluginProjection =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.PluginProjection,
+ static entity => entity.ServerGuid == 0,
+ _staticPresentation.RemovePluginProjection),
+ LandblockRetirementStage.Terrain =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.Terrain,
+ () => _render.RemoveTerrain(ticket.LandblockId)),
+ LandblockRetirementStage.Physics =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.Physics,
+ () =>
+ {
+ if (ticket.Kind == LandblockRetirementKind.Full)
+ _physics.RemoveLandblock(ticket.LandblockId);
+ else
+ _physics.DemoteToTerrain(ticket.LandblockId);
+ }),
+ LandblockRetirementStage.CellVisibility =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.CellVisibility,
+ () => _render.RemoveCellVisibility(ticket.LandblockId)),
+ LandblockRetirementStage.BuildingRegistry =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.BuildingRegistry,
+ () => _render.RemoveBuildingRegistry(ticket.LandblockId)),
+ LandblockRetirementStage.EnvironmentCells =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.EnvironmentCells,
+ () => _render.RemoveEnvironmentCells(ticket.LandblockId)),
+ _ => LandblockRetirementOperationResult.NoWork,
+ };
+ }
+
private void RemoveLighting(WorldEntity entity)
{
if (entity.ServerGuid == 0)
diff --git a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
index 03fea9d6..36409ea5 100644
--- a/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
+++ b/src/AcDream.App/Streaming/LandblockRetirementCoordinator.cs
@@ -20,6 +20,13 @@ public enum LandblockRetirementStage : ushort
LegacyPresentation = 1 << 11,
}
+internal enum LandblockRetirementOperationResult : byte
+{
+ NoWork,
+ Progressed,
+ Failed,
+}
+
///
/// One retryable landblock-retirement ledger. Successful owner operations are
/// committed once; a later attempt resumes at the exact failed owner/entity.
@@ -70,6 +77,29 @@ public sealed class LandblockRetirementTicket
}
}
+ internal LandblockRetirementOperationResult RunOnceStep(
+ LandblockRetirementStage stage,
+ Action operation)
+ {
+ ValidateSingleStage(stage);
+ ArgumentNullException.ThrowIfNull(operation);
+ if ((CompletedStages & stage) != 0)
+ return LandblockRetirementOperationResult.NoWork;
+
+ try
+ {
+ operation();
+ CompletedStages |= stage;
+ _failures.Remove(stage);
+ return LandblockRetirementOperationResult.Progressed;
+ }
+ catch (Exception error)
+ {
+ _failures[stage] = error;
+ return LandblockRetirementOperationResult.Failed;
+ }
+ }
+
public bool RunForEachEntity(
LandblockRetirementStage stage,
Func predicate,
@@ -109,6 +139,74 @@ public sealed class LandblockRetirementTicket
return true;
}
+ ///
+ /// Advances exactly one entity cursor, including predicate misses. This
+ /// makes both owner work and the scan needed to find it visible to the
+ /// frame budget.
+ ///
+ internal LandblockRetirementOperationResult RunEntityStep(
+ LandblockRetirementStage stage,
+ Func predicate,
+ Action operation)
+ {
+ ValidateSingleStage(stage);
+ ArgumentNullException.ThrowIfNull(predicate);
+ ArgumentNullException.ThrowIfNull(operation);
+ if ((CompletedStages & stage) != 0)
+ return LandblockRetirementOperationResult.NoWork;
+
+ _entityCursors.TryGetValue(stage, out int cursor);
+ if (cursor >= Entities.Count)
+ {
+ CompletedStages |= stage;
+ _entityCursors.Remove(stage);
+ _failures.Remove(stage);
+ return LandblockRetirementOperationResult.Progressed;
+ }
+
+ WorldEntity entity = Entities[cursor];
+ if (predicate(entity))
+ {
+ try
+ {
+ operation(entity);
+ }
+ catch (Exception error)
+ {
+ _entityCursors[stage] = cursor;
+ _failures[stage] = error;
+ return LandblockRetirementOperationResult.Failed;
+ }
+ }
+
+ cursor++;
+ if (cursor == Entities.Count)
+ {
+ CompletedStages |= stage;
+ _entityCursors.Remove(stage);
+ _failures.Remove(stage);
+ }
+ else
+ {
+ _entityCursors[stage] = cursor;
+ }
+
+ return LandblockRetirementOperationResult.Progressed;
+ }
+
+ internal LandblockRetirementStage NextIncompleteStage
+ {
+ get
+ {
+ ushort remaining = (ushort)(RequiredStages & ~CompletedStages);
+ if (remaining == 0)
+ return LandblockRetirementStage.None;
+
+ ushort lowest = (ushort)(remaining & (ushort)-(short)remaining);
+ return (LandblockRetirementStage)lowest;
+ }
+ }
+
internal void BeginAttempt() => _callbackFailure = null;
internal void RecordCallbackFailure(Exception error) =>
@@ -170,9 +268,13 @@ public sealed class LandblockRetirementCoordinator
private readonly GpuWorldState _state;
private readonly Action _advancePresentation;
+ private readonly Func<
+ LandblockRetirementTicket,
+ LandblockRetirementOperationResult>? _advancePresentationStep;
private readonly Func
_requiredPresentationStages;
private readonly Dictionary> _pending = new();
+ private readonly Queue _pendingOrder = new();
private readonly List _completedIds = new();
private readonly Queue _requests = new();
private readonly HashSet _seenDuringDrain = new();
@@ -187,12 +289,30 @@ public sealed class LandblockRetirementCoordinator
ArgumentNullException.ThrowIfNull(advancePresentation);
_state = state;
_advancePresentation = advancePresentation;
+ _advancePresentationStep = null;
_requiredPresentationStages = requiredPresentationStages
?? (kind => kind == LandblockRetirementKind.Full
? ProductionPresentationStages
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
}
+ private LandblockRetirementCoordinator(
+ GpuWorldState state,
+ Func
+ advancePresentationStep,
+ Action advancePresentation,
+ Func
+ requiredPresentationStages)
+ {
+ _state = state ?? throw new ArgumentNullException(nameof(state));
+ _advancePresentationStep = advancePresentationStep
+ ?? throw new ArgumentNullException(nameof(advancePresentationStep));
+ _advancePresentation = advancePresentation
+ ?? throw new ArgumentNullException(nameof(advancePresentation));
+ _requiredPresentationStages = requiredPresentationStages
+ ?? throw new ArgumentNullException(nameof(requiredPresentationStages));
+ }
+
public int PendingCount { get; private set; }
public bool IsPending(uint landblockId) =>
@@ -200,6 +320,7 @@ public sealed class LandblockRetirementCoordinator
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
+ internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
@@ -210,8 +331,81 @@ public sealed class LandblockRetirementCoordinator
public void Advance() => EnqueueRequest(
new Request(RequestKind.Advance, 0u));
+ internal static LandblockRetirementCoordinator CreateBudgeted(
+ GpuWorldState state,
+ Func
+ advancePresentationStep,
+ Action advancePresentation)
+ {
+ return new LandblockRetirementCoordinator(
+ state,
+ advancePresentationStep,
+ advancePresentation,
+ kind => kind == LandblockRetirementKind.Full
+ ? ProductionPresentationStages
+ : ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
+ }
+
+ ///
+ /// Advances stable FIFO retirement work under the caller's one frame meter.
+ /// Every reservation maps to exactly one entity cursor or owner stage.
+ ///
+ public void Advance(StreamingWorkMeter meter)
+ {
+ ArgumentNullException.ThrowIfNull(meter);
+ if (_advancePresentationStep is null)
+ {
+ Advance();
+ return;
+ }
+
+ while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
+ {
+ if (ticket.IsComplete)
+ {
+ RemoveCompletedTicket(ticket);
+ continue;
+ }
+
+ LandblockRetirementStage stage = ticket.NextIncompleteStage;
+ if (stage == LandblockRetirementStage.None)
+ throw new InvalidOperationException(
+ "An incomplete retirement ticket has no unfinished stage.");
+
+ StreamingWorkCost cost = new(
+ EntityOperations: 1,
+ GlRetireOperations:
+ stage is LandblockRetirementStage.MeshReferences
+ or LandblockRetirementStage.Terrain
+ ? 1
+ : 0);
+ StreamingWorkAdmission admission = meter.TryReserve(
+ cost,
+ $"retire-{stage}-0x{ticket.LandblockId:X8}");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return;
+
+ LandblockRetirementOperationResult result = AdvanceTicketOne(ticket);
+ if (result == LandblockRetirementOperationResult.Failed)
+ {
+ meter.Fail();
+ return;
+ }
+
+ meter.Complete();
+ if (ticket.IsComplete)
+ RemoveCompletedTicket(ticket);
+ }
+ }
+
private void AdvanceCore()
{
+ if (_advancePresentationStep is not null)
+ {
+ AdvanceBudgetedEagerAttempt();
+ return;
+ }
+
if (_pending.Count == 0)
return;
@@ -278,6 +472,9 @@ public sealed class LandblockRetirementCoordinator
if (existing[i].Kind == kind)
{
LandblockRetirementTicket existingTicket = existing[i];
+ if (_advancePresentationStep is not null)
+ return;
+
AdvanceTicket(existingTicket);
if (existingTicket.IsComplete)
{
@@ -331,14 +528,19 @@ public sealed class LandblockRetirementCoordinator
}
existing.Add(ticket);
PendingCount++;
+ if (_advancePresentationStep is not null)
+ _pendingOrder.Enqueue(ticket);
- AdvanceTicket(ticket);
- if (ticket.IsComplete)
+ if (_advancePresentationStep is null)
{
- existing.Remove(ticket);
- PendingCount--;
- if (existing.Count == 0)
- _pending.Remove(canonical);
+ AdvanceTicket(ticket);
+ if (ticket.IsComplete)
+ {
+ existing.Remove(ticket);
+ PendingCount--;
+ if (existing.Count == 0)
+ _pending.Remove(canonical);
+ }
}
// Visibility is a delivered-by-attempt broadcast: every subscriber is
// invoked and failures are aggregated, but failed subscribers are not
@@ -380,6 +582,96 @@ public sealed class LandblockRetirementCoordinator
}
}
+ private void AdvanceBudgetedEagerAttempt()
+ {
+ if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
+ return;
+
+ if (ticket.IsComplete)
+ {
+ RemoveCompletedTicket(ticket);
+ return;
+ }
+
+ // Compatibility/debug callers explicitly asking for an unmetered
+ // advance retain the former one-attempt behavior: every independent
+ // stage is attempted once, while successful stages/entities remain
+ // committed in the same exact ticket. Production calls the metered
+ // overload and never enters this path.
+ AdvanceTicket(ticket);
+ if (ticket.IsComplete)
+ RemoveCompletedTicket(ticket);
+ }
+
+ private LandblockRetirementOperationResult AdvanceTicketOne(
+ LandblockRetirementTicket ticket)
+ {
+ ticket.BeginAttempt();
+ LandblockRetirementStage stage = ticket.NextIncompleteStage;
+ LandblockRetirementOperationResult result;
+ try
+ {
+ result = stage switch
+ {
+ LandblockRetirementStage.MeshReferences =>
+ ticket.RunOnceStep(
+ stage,
+ () => _state.ReleaseLandblockMeshReferences(
+ ticket.LandblockId)),
+ LandblockRetirementStage.StaticScripts =>
+ ticket.RunEntityStep(
+ stage,
+ static entity => entity.ServerGuid == 0,
+ _state.StopStaticEntityScript),
+ LandblockRetirementStage.Classification =>
+ ticket.RunOnceStep(
+ stage,
+ () => _state.InvalidateLandblockClassification(
+ ticket.LandblockId)),
+ LandblockRetirementStage.None =>
+ LandblockRetirementOperationResult.NoWork,
+ _ => _advancePresentationStep!(ticket),
+ };
+ }
+ catch (Exception error)
+ {
+ ticket.RecordCallbackFailure(error);
+ result = LandblockRetirementOperationResult.Failed;
+ }
+
+ if (ticket.TryTakeNewFailure(out Exception? failure))
+ {
+ Console.WriteLine(
+ $"streaming: retirement for 0x{ticket.LandblockId:X8} " +
+ $"({ticket.Kind}) remains pending: {failure}");
+ }
+
+ return result;
+ }
+
+ private void RemoveCompletedTicket(LandblockRetirementTicket ticket)
+ {
+ if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head)
+ || !ReferenceEquals(head, ticket))
+ {
+ throw new InvalidOperationException(
+ "Retirement FIFO completion did not match its active head.");
+ }
+ _pendingOrder.Dequeue();
+
+ uint id = Canonicalize(ticket.LandblockId);
+ if (!_pending.TryGetValue(id, out List? tickets)
+ || !tickets.Remove(ticket))
+ {
+ throw new InvalidOperationException(
+ "Completed retirement ticket is missing from its owner ledger.");
+ }
+
+ PendingCount--;
+ if (tickets.Count == 0)
+ _pending.Remove(id);
+ }
+
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;
diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
index 4b647776..05e8a7df 100644
--- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
+++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs
@@ -674,6 +674,13 @@ internal sealed class LocalPlayerTeleportController
+ $"new lb=({landblockX},{landblockY}) "
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
+ // Retail SmartBox enters blocking_for_cells before old-world object,
+ // physics, landscape, and ambient owners may advance again. Begin the
+ // reveal generation before a recenter can start detaching that world.
+ _worldReveal.Begin(WorldRevealKind.Portal);
+ if (!IsCurrentLifetime(generation, sequence))
+ return false;
+
Vector3 worldPosition;
if (transition.ChangesStreamingCenter)
{
@@ -707,9 +714,6 @@ internal sealed class LocalPlayerTeleportController
_pendingCell = position.LandblockId;
_holdSeconds = 0f;
_forced = false;
- _worldReveal.Begin(WorldRevealKind.Portal);
- if (!IsCurrentLifetime(generation, sequence))
- return false;
_streaming.SetPriority(
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
NearRingRadius);
@@ -746,7 +750,7 @@ internal sealed class LocalPlayerTeleportController
_holdSeconds = 0f;
_forced = false;
- bool originConverged = _streaming.ResetRecenter(clearSession);
+ _streaming.ResetRecenter(clearSession);
if (_lifetimeGeneration != generation)
return generation;
@@ -763,12 +767,6 @@ internal sealed class LocalPlayerTeleportController
_streaming.ClearPriority();
- if (clearSession && !originConverged)
- {
- throw new InvalidOperationException(
- "The ending session's streaming-origin retirement has not converged.");
- }
-
return generation;
}
diff --git a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs
index f432b517..8b7a7d26 100644
--- a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs
+++ b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs
@@ -167,6 +167,21 @@ internal sealed class StreamingCompletionQueue
_retainedCpuBytes = 0;
}
+ public bool TryRemoveOne()
+ {
+ for (int priority = 0; priority < _queues.Length; priority++)
+ {
+ Queue queue = _queues[priority];
+ if (!queue.TryDequeue(out StreamingQueuedCompletion completion))
+ continue;
+
+ Release(completion);
+ return true;
+ }
+
+ return false;
+ }
+
public StreamingCompletionQueueSnapshot CaptureSnapshot()
{
long now = Stopwatch.GetTimestamp();
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 6dc08c8d..900a924e 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -29,11 +29,24 @@ public sealed class StreamingController : IStreamingFrameBackend
public List? ResidentIds;
public int RetirementCursor;
public bool PreparationCommitted;
+ public IEnumerator? ResidentEnumerator;
public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured;
public bool DestinationLoadEnqueued;
}
+ private sealed class FullWindowRetirement
+ {
+ public bool GenerationAdvanced;
+ public bool PendingLoadsCleared;
+ public bool CompletionQueueCleared;
+ public bool RegionCleared;
+ public List? ResidentIds;
+ public IEnumerator? ResidentEnumerator;
+ public int RetirementCursor;
+ public bool PreparationCommitted;
+ }
+
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly ILandblockCompletionSource _completionSource;
@@ -55,6 +68,7 @@ public sealed class StreamingController : IStreamingFrameBackend
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
private OriginRecenterRetirement? _originRecenterRetirement;
+ private FullWindowRetirement? _fullWindowRetirement;
private bool _advancingOriginRecenter;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
@@ -459,7 +473,7 @@ public sealed class StreamingController : IStreamingFrameBackend
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
- _presentation.BeginNearLayerRetirement(canonical);
+ _presentation.EnqueueNearLayerRetirement(canonical);
}
private bool AdvanceGeneration()
@@ -530,9 +544,37 @@ public sealed class StreamingController : IStreamingFrameBackend
_activeWorkMeter = meter;
try
{
+ if (_fullWindowRetirement is not null)
+ {
+ bool retirementWasPending =
+ _presentation.PendingRetirementCount != 0;
+ TryAdvanceFullWindowRetirement(meter);
+ if (_presentation.UsesBudgetedRetirementSteps
+ || retirementWasPending)
+ {
+ _presentation.AdvanceRetirements(meter);
+ }
+ if (_fullWindowRetirement is
+ {
+ PreparationCommitted: true,
+ }
+ && _presentation.PendingRetirementCount == 0)
+ {
+ _fullWindowRetirement = null;
+ }
+ return;
+ }
+
if (_originRecenterRetirement is not null)
{
- _presentation.AdvanceRetirements();
+ bool retirementWasPending =
+ _presentation.PendingRetirementCount != 0;
+ TryAdvanceOriginRecenterPreparation(meter);
+ if (_presentation.UsesBudgetedRetirementSteps
+ || retirementWasPending)
+ {
+ _presentation.AdvanceRetirements(meter);
+ }
return;
}
@@ -544,12 +586,12 @@ public sealed class StreamingController : IStreamingFrameBackend
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
- // Presentation-owner failures never roll back spatial state. Resume
- // unfinished owner ledgers before accepting replacement publications.
- _presentation.AdvanceRetirements();
// Complete an admitted publication before this frame can mutate its
// desired tier or spatial residence. A later recenter may then demote
// or retire the fully known owner set through the normal ledger.
+ bool retirementWasPendingAtFrameStart =
+ _presentation.PendingRetirementCount != 0;
+ _presentation.AdvanceRetirements(meter);
if (!ConvergePendingPublications())
return;
@@ -584,6 +626,12 @@ public sealed class StreamingController : IStreamingFrameBackend
}
DrainAndApply();
+ // Retirement is cleanup after immediate spatial withdrawal. Spend
+ // remaining capacity only after visible/destination publication
+ // work has had access to this frame's shared meter.
+ if (_presentation.UsesBudgetedRetirementSteps
+ && !retirementWasPendingAtFrameStart)
+ _presentation.AdvanceRetirements(meter);
}
finally
{
@@ -882,10 +930,13 @@ public sealed class StreamingController : IStreamingFrameBackend
}
///
- /// Starts a reload of the current streaming window. Logical residency is
- /// detached immediately; render, physics, and static-lighting owners then
- /// converge through the retained presentation-retirement ledger before
- /// bootstraps the window again.
+ /// Starts a reload of the current streaming window. The next and later
+ /// calls invalidate accepted work, capture and detach
+ /// stable residents, and converge render, physics, and static-lighting
+ /// owners under the shared frame budget before
+ /// bootstraps the window again. Hard login/portal transitions separately
+ /// publish their immediate observable edge through
+ /// .
/// Shared-origin teleports use instead so
/// every old-window owner retires while the old coordinate frame is active.
///
@@ -916,10 +967,8 @@ public sealed class StreamingController : IStreamingFrameBackend
throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
- if (!TryAdvanceOriginRecenterPreparation())
- return false;
- _presentation.AdvanceRetirements();
- return _presentation.PendingRetirementCount == 0;
+ return _originRecenterRetirement.PreparationCommitted
+ && _presentation.PendingRetirementCount == 0;
}
///
@@ -1040,8 +1089,9 @@ public sealed class StreamingController : IStreamingFrameBackend
return true;
}
- private bool TryAdvanceOriginRecenterPreparation()
+ private bool TryAdvanceOriginRecenterPreparation(StreamingWorkMeter meter)
{
+ ArgumentNullException.ThrowIfNull(meter);
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
@@ -1055,58 +1105,159 @@ public sealed class StreamingController : IStreamingFrameBackend
{
if (!transaction.RadiiConverged)
{
- if (_pendingRadiiReconfiguration is not null)
- AdvanceRadiiReconfiguration();
+ if (_pendingRadiiReconfiguration is not null
+ && !TryRunStreamingWork(
+ meter,
+ new StreamingWorkCost(
+ EntityOperations: Math.Max(
+ 1,
+ _pendingRadiiReconfiguration.Mutations.Count)),
+ "recenter-radii-convergence",
+ () =>
+ {
+ AdvanceRadiiReconfiguration();
+ return _pendingRadiiReconfiguration is null;
+ }))
+ {
+ return false;
+ }
if (_pendingRadiiReconfiguration is not null)
return false;
transaction.RadiiConverged = true;
}
if (!transaction.GenerationAdvanced)
{
+ // AdvanceGeneration meters any retained publication it must
+ // converge through the active frame meter. The token increment
+ // itself is constant-time and must not create a nested meter
+ // reservation around that retry.
if (!AdvanceGeneration())
return false;
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
{
- try
+ bool ClearPendingLoads()
{
- _clearPendingLoads?.Invoke();
- transaction.PendingLoadsCleared = true;
+ try
+ {
+ _clearPendingLoads?.Invoke();
+ transaction.PendingLoadsCleared = true;
+ return true;
+ }
+ catch (StreamingMutationException error) when (error.MutationCommitted)
+ {
+ transaction.PendingLoadsCleared = true;
+ Console.WriteLine(
+ $"streaming: committed pending-load clear reported failure: {error}");
+ return false;
+ }
}
- catch (StreamingMutationException error) when (error.MutationCommitted)
- {
- transaction.PendingLoadsCleared = true;
- Console.WriteLine(
- $"streaming: committed pending-load clear reported failure: {error}");
+
+ if (!TryRunStreamingWork(
+ meter,
+ new StreamingWorkCost(EntityOperations: 1),
+ "recenter-clear-worker-inbox",
+ ClearPendingLoads))
return false;
- }
}
if (!transaction.CompletionQueueCleared)
{
- _completionQueue.Clear();
+ while (_completionQueue.Count != 0)
+ {
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(EntityOperations: 1),
+ "recenter-release-completion");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ if (!_completionQueue.TryRemoveOne())
+ {
+ meter.Fail();
+ throw new InvalidOperationException(
+ "Completion queue count changed during recenter release.");
+ }
+ meter.Complete();
+ }
transaction.CompletionQueueCleared = true;
}
if (!transaction.RegionCleared)
{
- _collapsed = false;
- _region = null;
+ if (!TryRunStreamingWork(
+ meter,
+ new StreamingWorkCost(EntityOperations: 1),
+ "recenter-clear-region",
+ () =>
+ {
+ _collapsed = false;
+ _region = null;
+ return true;
+ }))
+ {
+ return false;
+ }
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
- transaction.ResidentIds = new List(_state.LoadedLandblockIds);
- FullWindowRetirementCount++;
- LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
+ transaction.ResidentIds = [];
+ transaction.ResidentEnumerator =
+ _state.LoadedLandblockIds.GetEnumerator();
}
-
- while (transaction.RetirementCursor < transaction.ResidentIds.Count)
+ while (transaction.ResidentEnumerator is { } residentEnumerator)
{
- uint id = transaction.ResidentIds[transaction.RetirementCursor];
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(EntityOperations: 1),
+ "recenter-capture-resident-id");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ bool moved;
try
{
- _presentation.BeginFullRetirement(id);
+ moved = residentEnumerator.MoveNext();
+ if (moved)
+ transaction.ResidentIds.Add(residentEnumerator.Current);
+ else
+ {
+ residentEnumerator.Dispose();
+ transaction.ResidentEnumerator = null;
+ FullWindowRetirementCount++;
+ LastFullWindowRetirementLandblockCount =
+ transaction.ResidentIds.Count;
+ }
+ meter.Complete();
+ }
+ catch
+ {
+ meter.Fail();
+ throw;
+ }
+ }
+
+ List residentIds = transaction.ResidentIds
+ ?? throw new InvalidOperationException(
+ "Origin-recenter resident capture did not commit.");
+ while (transaction.RetirementCursor < residentIds.Count)
+ {
+ uint id = residentIds[transaction.RetirementCursor];
+ int entityCount = _state.TryGetLandblock(
+ id,
+ out LoadedLandblock? loaded)
+ ? loaded!.Entities.Count
+ : 0;
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(
+ EntityOperations: Math.Max(1, entityCount)),
+ $"recenter-detach-0x{id:X8}");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ try
+ {
+ _presentation.EnqueueFullRetirement(id);
transaction.RetirementCursor++;
+ meter.Complete();
}
catch (Exception error)
{
@@ -1116,6 +1267,7 @@ public sealed class StreamingController : IStreamingFrameBackend
// otherwise retain it for the next frame.
if (!_state.IsLoaded(id))
transaction.RetirementCursor++;
+ meter.Fail();
Console.WriteLine(
$"streaming: origin-recenter retirement for 0x{id:X8} " +
$"will resume: {error}");
@@ -1140,21 +1292,178 @@ public sealed class StreamingController : IStreamingFrameBackend
private void BeginFullWindowRetirement()
{
- if (!AdvanceGeneration())
- return;
- _collapsed = false;
- _clearPendingLoads?.Invoke();
- _completionQueue.Clear();
- // Commit the old region boundary before any presentation owner is
- // advanced. New same-id publications are fenced by the presentation
- // pipeline's retryable retirement ledger.
- _region = null;
- // Snapshot — RemoveLandblock mutates the loaded set we're iterating.
- var ids = new List(_state.LoadedLandblockIds);
- FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
- LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
- foreach (var id in ids)
- _presentation.BeginFullRetirement(id);
+ _fullWindowRetirement ??= new FullWindowRetirement();
+ }
+
+ private bool TryAdvanceFullWindowRetirement(StreamingWorkMeter meter)
+ {
+ FullWindowRetirement transaction = _fullWindowRetirement
+ ?? throw new InvalidOperationException(
+ "No full-window retirement transaction is pending.");
+ if (transaction.PreparationCommitted)
+ return true;
+
+ try
+ {
+ if (!transaction.GenerationAdvanced)
+ {
+ if (!AdvanceGeneration())
+ {
+ return false;
+ }
+ transaction.GenerationAdvanced = true;
+ }
+
+ if (!transaction.PendingLoadsCleared)
+ {
+ bool ClearPendingLoads()
+ {
+ try
+ {
+ _clearPendingLoads?.Invoke();
+ transaction.PendingLoadsCleared = true;
+ return true;
+ }
+ catch (StreamingMutationException error) when (error.MutationCommitted)
+ {
+ transaction.PendingLoadsCleared = true;
+ Console.WriteLine(
+ $"streaming: committed reload pending-load clear reported failure: {error}");
+ return false;
+ }
+ }
+
+ if (!TryRunStreamingWork(
+ meter,
+ new StreamingWorkCost(EntityOperations: 1),
+ "reload-clear-worker-inbox",
+ ClearPendingLoads))
+ {
+ return false;
+ }
+ }
+
+ if (!transaction.CompletionQueueCleared)
+ {
+ while (_completionQueue.Count != 0)
+ {
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(EntityOperations: 1),
+ "reload-release-completion");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ if (!_completionQueue.TryRemoveOne())
+ {
+ meter.Fail();
+ throw new InvalidOperationException(
+ "Completion queue count changed during reload release.");
+ }
+ meter.Complete();
+ }
+ transaction.CompletionQueueCleared = true;
+ }
+
+ if (!transaction.RegionCleared)
+ {
+ if (!TryRunStreamingWork(
+ meter,
+ new StreamingWorkCost(EntityOperations: 1),
+ "reload-clear-region",
+ () =>
+ {
+ _collapsed = false;
+ _region = null;
+ return true;
+ }))
+ {
+ return false;
+ }
+ transaction.RegionCleared = true;
+ }
+
+ if (transaction.ResidentIds is null)
+ {
+ transaction.ResidentIds = [];
+ transaction.ResidentEnumerator =
+ _state.LoadedLandblockIds.GetEnumerator();
+ }
+ while (transaction.ResidentEnumerator is { } residentEnumerator)
+ {
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(EntityOperations: 1),
+ "reload-capture-resident-id");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ bool moved;
+ try
+ {
+ moved = residentEnumerator.MoveNext();
+ if (moved)
+ transaction.ResidentIds.Add(residentEnumerator.Current);
+ else
+ {
+ residentEnumerator.Dispose();
+ transaction.ResidentEnumerator = null;
+ FullWindowRetirementCount++;
+ LastFullWindowRetirementLandblockCount =
+ transaction.ResidentIds.Count;
+ }
+ meter.Complete();
+ }
+ catch
+ {
+ meter.Fail();
+ throw;
+ }
+ }
+
+ List residentIds = transaction.ResidentIds
+ ?? throw new InvalidOperationException(
+ "Full-window resident capture did not commit.");
+ while (transaction.RetirementCursor < residentIds.Count)
+ {
+ uint id = residentIds[transaction.RetirementCursor];
+ int entityCount = _state.TryGetLandblock(
+ id,
+ out LoadedLandblock? loaded)
+ ? loaded!.Entities.Count
+ : 0;
+ StreamingWorkAdmission admission = meter.TryReserve(
+ new StreamingWorkCost(
+ EntityOperations: Math.Max(1, entityCount)),
+ $"reload-detach-0x{id:X8}");
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ try
+ {
+ _presentation.EnqueueFullRetirement(id);
+ transaction.RetirementCursor++;
+ meter.Complete();
+ }
+ catch (Exception error)
+ {
+ if (!_state.IsLoaded(id))
+ transaction.RetirementCursor++;
+ meter.Fail();
+ Console.WriteLine(
+ $"streaming: full-window retirement for 0x{id:X8} " +
+ $"will resume: {error}");
+ return false;
+ }
+ }
+
+ transaction.PreparationCommitted = true;
+ return true;
+ }
+ catch (Exception error)
+ {
+ Console.WriteLine(
+ $"streaming: full-window preparation will resume: {error}");
+ return false;
+ }
}
///
@@ -1209,6 +1518,32 @@ public sealed class StreamingController : IStreamingFrameBackend
}
}
+ private static bool TryRunStreamingWork(
+ StreamingWorkMeter meter,
+ StreamingWorkCost cost,
+ string stage,
+ Func operation)
+ {
+ StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
+ if (admission == StreamingWorkAdmission.Yielded)
+ return false;
+
+ try
+ {
+ bool completed = operation();
+ if (completed)
+ meter.Complete();
+ else
+ meter.Fail();
+ return completed;
+ }
+ catch
+ {
+ meter.Fail();
+ throw;
+ }
+ }
+
private void AdmitCompletions(StreamingWorkMeter meter)
{
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
@@ -1439,7 +1774,7 @@ public sealed class StreamingController : IStreamingFrameBackend
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
break;
case LandblockStreamResult.Unloaded unloaded:
- _presentation.BeginFullRetirement(unloaded.LandblockId);
+ _presentation.EnqueueFullRetirement(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
@@ -1458,14 +1793,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
{
if (_state.IsLoaded(landblockId))
- _presentation.BeginFullRetirement(landblockId);
+ _presentation.EnqueueFullRetirement(landblockId);
return;
}
if (desiredTier == LandblockStreamTier.Far
&& _state.IsNearTier(landblockId))
{
- _presentation.BeginNearLayerRetirement(landblockId);
+ _presentation.EnqueueNearLayerRetirement(landblockId);
}
}
diff --git a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
index c43fa647..af516bd0 100644
--- a/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
+++ b/src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs
@@ -37,9 +37,10 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
public bool IsPending => _pending is not null;
///
- /// Begins a new recenter transaction and performs its first retirement
- /// attempt immediately. A teleport replacement must call
- /// before supplying a different destination.
+ /// Begins a new recenter transaction. Its retained preparation and
+ /// retirement cursors advance only from
+ /// under that frame's shared work meter. A teleport replacement must call
+ /// before supplying a different destination.
///
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
{
@@ -71,8 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
}
///
- /// Advances retained presentation teardown. Returns true only after the
- /// new origin is committed and destination streaming has been unblocked.
+ /// Observes retained presentation teardown and commits the new origin once
+ /// the streaming frame owner has converged it. Returns true only after the
+ /// destination streaming gate has been released.
///
public bool Advance()
{
diff --git a/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
new file mode 100644
index 00000000..d582e070
--- /dev/null
+++ b/src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
@@ -0,0 +1,109 @@
+using AcDream.App.Audio;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Read-only generation gate shared by world simulation and presentation.
+/// A hard login/portal reveal boundary makes the prior world unavailable
+/// immediately while its physical owners retire over later frames.
+///
+public interface IWorldGenerationAvailability
+{
+ bool IsWorldAvailable { get; }
+ long QuiescedGeneration { get; }
+}
+
+internal sealed class AlwaysAvailableWorldGeneration
+ : IWorldGenerationAvailability
+{
+ public static AlwaysAvailableWorldGeneration Instance { get; } = new();
+ public bool IsWorldAvailable => true;
+ public long QuiescedGeneration => 0;
+}
+
+///
+/// Single-writer state behind .
+/// Superseding reveal generations stay continuously quiesced; only the exact
+/// active generation may reopen the world.
+///
+internal sealed class WorldGenerationAvailabilityState
+ : IWorldGenerationAvailability
+{
+ public bool IsWorldAvailable => QuiescedGeneration == 0;
+ public long QuiescedGeneration { get; private set; }
+
+ public void Begin(long generation)
+ {
+ if (generation <= 0)
+ throw new ArgumentOutOfRangeException(nameof(generation));
+
+ QuiescedGeneration = generation;
+ }
+
+ public bool End(long generation)
+ {
+ if (generation <= 0 || QuiescedGeneration != generation)
+ return false;
+
+ QuiescedGeneration = 0;
+ return true;
+ }
+}
+
+///
+/// Owns the immediate observable edge of retail
+/// SmartBox::UseTime @ 0x00455410's blocking_for_cells
+/// branch. Deferred teardown may retain memory, but the old generation cannot
+/// keep rendering, targeting, ticking, or producing world audio.
+///
+internal sealed class WorldGenerationQuiescence
+{
+ private readonly WorldGenerationAvailabilityState _availability;
+ private readonly SelectionState _selection;
+ private readonly GpuWorldState _world;
+ private readonly IWorldAudioQuiescence? _audio;
+
+ public WorldGenerationQuiescence(
+ WorldGenerationAvailabilityState availability,
+ SelectionState selection,
+ GpuWorldState world,
+ IWorldAudioQuiescence? audio)
+ {
+ _availability = availability
+ ?? throw new ArgumentNullException(nameof(availability));
+ _selection = selection ?? throw new ArgumentNullException(nameof(selection));
+ _world = world ?? throw new ArgumentNullException(nameof(world));
+ _audio = audio;
+ }
+
+ public IWorldGenerationAvailability Availability => _availability;
+
+ public void Begin(long generation)
+ {
+ bool firstEdge = _availability.IsWorldAvailable;
+ uint? selected = firstEdge ? _selection.SelectedObjectId : null;
+ bool clearWorldSelection =
+ selected is uint selectedGuid
+ && _world.IsLiveEntityVisible(selectedGuid);
+
+ _availability.Begin(generation);
+ if (!firstEdge)
+ return;
+
+ if (clearWorldSelection)
+ {
+ _selection.Clear(
+ SelectionChangeSource.System,
+ SelectionChangeReason.Cleared);
+ }
+
+ _audio?.SuspendWorldAudio();
+ }
+
+ public void End(long generation)
+ {
+ if (_availability.End(generation))
+ _audio?.ResumeWorldAudio();
+ }
+}
diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
index 555e1feb..d24c0603 100644
--- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
+++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs
@@ -10,6 +10,8 @@ internal sealed class WorldRevealCoordinator
{
private readonly WorldRevealReadinessBarrier _readiness;
private readonly WorldRevealLifecycleTelemetry _lifecycle;
+ private readonly WorldGenerationQuiescence? _quiescence;
+ private long _activeGeneration;
public WorldRevealCoordinator(
Func isRenderNeighborhoodReady,
@@ -19,7 +21,8 @@ internal sealed class WorldRevealCoordinator
Action prepareCompositeTextures,
Action invalidateCompositeTextures,
Func isSpawnClaimUnhydratable,
- Action? log = null)
+ Action? log = null,
+ WorldGenerationQuiescence? quiescence = null)
{
_readiness = new WorldRevealReadinessBarrier(
isRenderNeighborhoodReady,
@@ -30,6 +33,7 @@ internal sealed class WorldRevealCoordinator
invalidateCompositeTextures,
isSpawnClaimUnhydratable);
_lifecycle = new WorldRevealLifecycleTelemetry(log);
+ _quiescence = quiescence;
}
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
@@ -38,7 +42,10 @@ internal sealed class WorldRevealCoordinator
public long Begin(WorldRevealKind kind)
{
_readiness.Begin();
- return _lifecycle.Begin(kind);
+ long generation = _lifecycle.Begin(kind);
+ _activeGeneration = generation;
+ _quiescence?.Begin(generation);
+ return generation;
}
///
@@ -63,7 +70,25 @@ internal sealed class WorldRevealCoordinator
public void ObserveWorldViewportVisible() =>
_lifecycle.ObserveWorldViewportVisible();
- public void Complete() => _lifecycle.Complete();
+ public void Complete()
+ {
+ _lifecycle.Complete();
+ EndQuiescence();
+ }
- public void Cancel() => _lifecycle.Cancel();
+ public void Cancel()
+ {
+ _lifecycle.Cancel();
+ EndQuiescence();
+ }
+
+ private void EndQuiescence()
+ {
+ long generation = _activeGeneration;
+ if (generation == 0)
+ return;
+
+ _activeGeneration = 0;
+ _quiescence?.End(generation);
+ }
}
diff --git a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs
index a55a7508..16d3540c 100644
--- a/src/AcDream.App/Update/UpdateFrameOrchestrator.cs
+++ b/src/AcDream.App/Update/UpdateFrameOrchestrator.cs
@@ -1,5 +1,7 @@
namespace AcDream.App.Update;
+using AcDream.App.Streaming;
+
///
/// The host-supplied input for one update callback.
///
@@ -28,10 +30,13 @@ internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource
{
public double CurrentScriptTime { get; private set; }
- public UpdateFrameTiming Advance(UpdateFrameInput input)
+ public UpdateFrameTiming Advance(
+ UpdateFrameInput input,
+ bool advanceScriptClock = true)
{
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
- CurrentScriptTime += deltaSeconds;
+ if (advanceScriptClock)
+ CurrentScriptTime += deltaSeconds;
return new UpdateFrameTiming(
deltaSeconds,
(float)deltaSeconds,
@@ -122,6 +127,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
private readonly ILocalPlayerTeleportFramePhase _teleport;
private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry;
private readonly ICameraFramePhase _camera;
+ private readonly IWorldGenerationAvailability _availability;
public UpdateFrameOrchestrator(
IUpdateFrameTeardownPhase teardown,
@@ -134,7 +140,8 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
ILiveEntityLivenessFramePhase liveness,
ILocalPlayerTeleportFramePhase teleport,
IPlayerModeAutoEntryFramePhase playerModeAutoEntry,
- ICameraFramePhase camera)
+ ICameraFramePhase camera,
+ IWorldGenerationAvailability? availability = null)
{
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink));
@@ -149,6 +156,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_playerModeAutoEntry = playerModeAutoEntry
?? throw new ArgumentNullException(nameof(playerModeAutoEntry));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
+ _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(UpdateFrameInput input)
@@ -165,12 +173,15 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_failureSink.ReportTeardownFailure(error);
}
- UpdateFrameTiming timing = _clock.Advance(input);
+ UpdateFrameTiming timing = _clock.Advance(
+ input,
+ advanceScriptClock: _availability.IsWorldAvailable);
_scriptClockPublisher.PublishTime(timing.ScriptTime);
_streaming.Tick();
_input.Tick(timing);
_liveFrame.Tick(timing.SimulationDeltaSecondsSingle);
- _liveness.Tick();
+ if (_availability.IsWorldAvailable)
+ _liveness.Tick();
_teleport.Tick(timing.SimulationDeltaSecondsSingle);
_playerModeAutoEntry.TryEnter();
_camera.Tick(timing);
diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
index 6e9cbb80..6fb0cb44 100644
--- a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
+++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs
@@ -24,13 +24,15 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
private readonly ILiveSessionFramePhase _session;
private readonly IPostNetworkCommandFramePhase _localPlayer;
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
+ private readonly IWorldGenerationAvailability _availability;
public RetailLiveFrameCoordinator(
ILiveObjectFramePhase objects,
GpuWorldState worldState,
ILiveSessionFramePhase session,
IPostNetworkCommandFramePhase localPlayer,
- ILiveSpatialReconcilePhase spatialReconciler)
+ ILiveSpatialReconcilePhase spatialReconciler,
+ IWorldGenerationAvailability? availability = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
@@ -38,16 +40,19 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_spatialReconciler = spatialReconciler
?? throw new ArgumentNullException(nameof(spatialReconciler));
+ _availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(float deltaSeconds)
{
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
- _objects.Tick(frameDelta);
+ if (_availability.IsWorldAvailable)
+ _objects.Tick(frameDelta);
using (_worldState.BeginMutationBatch())
_session.Tick();
_localPlayer.RunPostNetworkCommandPhase();
- _spatialReconciler.Reconcile();
+ if (_availability.IsWorldAvailable)
+ _spatialReconciler.Reconcile();
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
index 9321e78a..b77db2cc 100644
--- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
+++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs
@@ -152,6 +152,13 @@ public sealed class LiveSessionResetPlanTests
Assert.Equal(1, identityResets);
failRetirement = false;
+ for (int frame = 0;
+ frame < 64
+ && !controller.IsOriginRecenterRetirementComplete();
+ frame++)
+ {
+ controller.Tick(0x53, 0x53);
+ }
plan.Execute();
Assert.Equal(2, identityResets);
diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
index 0fb918dc..1f2940b9 100644
--- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
@@ -3,6 +3,7 @@ using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
+using AcDream.App.Streaming;
using AcDream.Core.Rendering;
using AcDream.Core.World;
@@ -21,6 +22,23 @@ public sealed class WorldSceneRendererTests
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
+ [Fact]
+ public void QuiescedGeneration_PublishesEmptySelectionFrameAndSkipsEveryWorldOwner()
+ {
+ var availability = new WorldGenerationAvailabilityState();
+ availability.Begin(4);
+ var rig = new Rig(
+ portalVisible: false,
+ waitingForLogin: false,
+ clipRoot: null,
+ availability: availability);
+
+ WorldRenderFrameOutcome result = rig.Renderer.Render(default);
+
+ Assert.Equal(default, result);
+ Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
+ }
+
[Fact]
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
{
@@ -332,7 +350,8 @@ public sealed class WorldSceneRendererTests
bool waitingForLogin,
LoadedCell? clipRoot,
bool? playerSeenOutside = null,
- EnvironOverride environOverride = EnvironOverride.None)
+ EnvironOverride environOverride = EnvironOverride.None,
+ IWorldGenerationAvailability? availability = null)
{
Calls = [];
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
@@ -385,7 +404,8 @@ public sealed class WorldSceneRendererTests
new PViewCells(),
Passes,
new WorldRenderRangeState(4, 12),
- diagnostics);
+ diagnostics,
+ availability);
}
public List Calls { get; }
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
index 36c8ee85..d78ab1c8 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs
@@ -137,6 +137,7 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
+ controller.Tick(0x19, 0x19);
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(centerId));
@@ -144,12 +145,11 @@ public sealed class LandblockPresentationPipelineTests
Assert.Equal(1, controller.PendingRetirementCount);
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
- controller.Tick(0x19, 0x19);
Assert.Empty(enqueued);
- Assert.True(neighborAttempts >= 2);
+ Assert.Equal(1, neighborAttempts);
failNeighbor = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x19, 0x19));
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
Assert.Equal(0, controller.PendingRetirementCount);
@@ -187,12 +187,13 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
+ controller.Tick(0x30, 0x30);
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
Assert.True(state.IsLoaded(landblockId));
Assert.Equal(0, controller.PendingRetirementCount);
failClear = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x30, 0x30));
Assert.Equal(2, clearAttempts);
Assert.False(state.IsLoaded(landblockId));
@@ -234,13 +235,15 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false));
+ for (int frame = 0; frame < 16 && observerCalls == 0; frame++)
+ controller.Tick(0x42, 0x42);
Assert.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(firstId));
Assert.False(state.IsLoaded(secondId));
Assert.Equal(1, observerCalls);
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x42, 0x42));
Assert.Equal((0x43, 0x44), (origin.CenterX, origin.CenterY));
Assert.Equal([firstId, secondId], removed);
}
@@ -281,13 +284,14 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
+ controller.Tick(0x45, 0x45);
Assert.Same(player, Assert.Single(state.DrainRescued()));
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x45, 0x45));
Assert.Empty(state.DrainRescued());
state.AddLandblock(new LoadedLandblock(
destinationId,
@@ -344,7 +348,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(recenter.IsPending);
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x50, 0x50));
Assert.False(recenter.IsPending);
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
@@ -384,7 +388,12 @@ public sealed class LandblockPresentationPipelineTests
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
recenter.Reset();
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(
+ recenter,
+ controller,
+ 0x52,
+ 0x52,
+ insideDungeon: true));
Assert.Equal((0x52, 0x52), (origin.CenterX, origin.CenterY));
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
@@ -423,7 +432,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
recenter.Reset(sessionEnding: true);
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x53, 0x53));
Assert.Empty(enqueued);
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
@@ -464,7 +473,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(origin.TryInitialize(0x61, 0x62));
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x61, 0x62));
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
}
@@ -494,7 +503,7 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false));
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x10, 0x10));
Assert.Equal(1, clearAttempts);
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
@@ -537,10 +546,11 @@ public sealed class LandblockPresentationPipelineTests
removed.Add(id);
if (id == firstId)
Assert.False(controller!.IsOriginRecenterRetirementComplete());
- });
+ });
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
- Assert.True(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
+ Assert.False(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
+ Assert.True(Converge(recenter, controller, 0x70, 0x70));
Assert.Equal(1, clearCalls);
Assert.Equal([firstId, secondId], removed);
@@ -585,7 +595,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.Empty(enqueued);
failRetirement = false;
- Assert.True(recenter.Advance());
+ Assert.True(Converge(recenter, controller, 0x72, 0x72));
controller.Tick(0x73, 0x73);
Assert.Equal(1, controller.NearRadius);
@@ -630,6 +640,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(recenter.IsPending);
controller.Tick(0x30, 0x30, insideDungeon: true);
Assert.Empty(enqueued);
+ Assert.False(recenter.Advance());
failDungeonEnqueue = false;
Assert.True(recenter.Advance());
@@ -1066,6 +1077,8 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(state.IsNearTier(landblockId));
controller.ForceReloadWindow();
+ for (int frame = 0; frame < 16 && state.IsLoaded(landblockId); frame++)
+ controller.Tick(0x80, 0x80);
Assert.False(state.IsLoaded(landblockId));
Assert.Equal(1, presentationCalls);
@@ -1276,6 +1289,25 @@ public sealed class LandblockPresentationPipelineTests
Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase);
}
+ private static bool Converge(
+ StreamingOriginRecenterCoordinator recenter,
+ StreamingController controller,
+ int observerX,
+ int observerY,
+ bool insideDungeon = false,
+ int maximumFrames = 64)
+ {
+ for (int frame = 0; frame < maximumFrames; frame++)
+ {
+ if (recenter.Advance())
+ return true;
+
+ controller.Tick(observerX, observerY, insideDungeon);
+ }
+
+ return recenter.Advance();
+ }
+
private static LandblockBuild BuildWithShell(
uint landblockId,
params WorldEntity[] entities)
diff --git a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs
index 65c1a335..d58fc92e 100644
--- a/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LandblockRetirementCoordinatorTests.cs
@@ -51,6 +51,101 @@ public sealed class LandblockRetirementCoordinatorTests
Assert.Equal(1, calls[3]);
}
+ [Fact]
+ public void BudgetedRetirement_AdvancesAtMostTheAdmittedAtomicEntityWork()
+ {
+ const uint landblockId = 0x2021FFFFu;
+ WorldEntity[] entities =
+ [
+ Entity(1),
+ Entity(2),
+ Entity(3),
+ Entity(4),
+ ];
+ var state = StateWith(landblockId, entities);
+ var lightingCalls = new Dictionary();
+ LandblockRetirementCoordinator coordinator =
+ LandblockRetirementCoordinator.CreateBudgeted(
+ state,
+ ticket => AdvancePresentationStep(
+ ticket,
+ entity => lightingCalls[entity.Id] =
+ lightingCalls.GetValueOrDefault(entity.Id) + 1),
+ ticket => CompletePresentation(
+ ticket,
+ lighting: entity => lightingCalls[entity.Id] =
+ lightingCalls.GetValueOrDefault(entity.Id) + 1));
+ coordinator.BeginFull(landblockId);
+
+ Assert.False(state.IsLoaded(landblockId));
+ Assert.Equal(1, coordinator.PendingCount);
+
+ int frames = 0;
+ while (coordinator.PendingCount != 0 && frames++ < 64)
+ {
+ var meter = new StreamingWorkMeter(Budget(maxEntityOperations: 2));
+ coordinator.Advance(meter);
+ meter.FinishFrame();
+ Assert.InRange(meter.Snapshot.Used.EntityOperations, 0, 2);
+ }
+
+ Assert.InRange(frames, 2, 64);
+ Assert.Equal(0, coordinator.PendingCount);
+ Assert.Equal(
+ entities.Select(entity => entity.Id).Order(),
+ lightingCalls.Keys.Order());
+ Assert.All(lightingCalls.Values, count => Assert.Equal(1, count));
+ }
+
+ [Fact]
+ public void BudgetedRetirement_FailedEntityRetriesWithoutReplayingCommittedPrefix()
+ {
+ const uint landblockId = 0x2022FFFFu;
+ var state = StateWith(
+ landblockId,
+ Entity(1),
+ Entity(2),
+ Entity(3));
+ var calls = new Dictionary();
+ bool failSecond = true;
+ LandblockRetirementCoordinator coordinator =
+ LandblockRetirementCoordinator.CreateBudgeted(
+ state,
+ ticket => AdvancePresentationStep(
+ ticket,
+ entity =>
+ {
+ calls[entity.Id] = calls.GetValueOrDefault(entity.Id) + 1;
+ if (entity.Id == 2 && failSecond)
+ {
+ failSecond = false;
+ throw new InvalidOperationException(
+ "injected metered entity failure");
+ }
+ }),
+ ticket => CompletePresentation(ticket));
+ coordinator.BeginFull(landblockId);
+
+ var firstMeter = new StreamingWorkMeter(Budget(maxEntityOperations: 64));
+ coordinator.Advance(firstMeter);
+ firstMeter.FinishFrame();
+
+ Assert.Equal(1, coordinator.PendingCount);
+ Assert.Equal(1, calls[1]);
+ Assert.Equal(1, calls[2]);
+ Assert.False(calls.ContainsKey(3));
+ Assert.Equal(1, firstMeter.Snapshot.FailureCount);
+
+ var retryMeter = new StreamingWorkMeter(Budget(maxEntityOperations: 64));
+ coordinator.Advance(retryMeter);
+ retryMeter.FinishFrame();
+
+ Assert.Equal(0, coordinator.PendingCount);
+ Assert.Equal(1, calls[1]);
+ Assert.Equal(2, calls[2]);
+ Assert.Equal(1, calls[3]);
+ }
+
[Fact]
public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending()
{
@@ -59,31 +154,88 @@ public sealed class LandblockRetirementCoordinatorTests
foreach (uint id in ids)
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty()));
- bool failOnce = true;
+ bool failRetirement = true;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
- if (ticket.LandblockId == ids[1] && failOnce)
+ if (ticket.LandblockId == ids[1] && failRetirement)
{
- failOnce = false;
throw new InvalidOperationException("injected terrain failure");
}
}));
var controller = Controller(state, coordinator);
controller.ForceReloadWindow();
+ for (int frame = 0;
+ frame < 16 && ids.Any(state.IsLoaded);
+ frame++)
+ {
+ controller.Tick(0x20, 0x20);
+ }
foreach (uint id in ids)
Assert.False(state.IsLoaded(id));
Assert.Equal(1, coordinator.PendingCount);
+ failRetirement = false;
coordinator.Advance();
Assert.Equal(0, coordinator.PendingCount);
}
+ [Fact]
+ public void ForceReload_CapturesAndDetachesThroughTheSharedEntityBudget()
+ {
+ uint[] ids =
+ [
+ 0x2323FFFFu,
+ 0x2424FFFFu,
+ 0x2525FFFFu,
+ ];
+ var state = new GpuWorldState();
+ foreach (uint id in ids)
+ {
+ state.AddLandblock(new LoadedLandblock(
+ id,
+ new LandBlock(),
+ Array.Empty()));
+ }
+
+ var coordinator = new LandblockRetirementCoordinator(
+ state,
+ ticket => CompletePresentation(ticket));
+ var controller = Controller(
+ state,
+ coordinator,
+ workBudgetOptions: new StreamingWorkBudgetOptions(
+ MaxUpdateMilliseconds: 1000,
+ MaxCompletionAdmissions: 64,
+ MaxAdoptedCpuBytes: 64 * StreamingWorkBudgetOptions.MiB,
+ MaxEntityOperations: 1,
+ MaxGpuUploadBytes: 64 * StreamingWorkBudgetOptions.MiB,
+ MaxGlRetireOperations: 64,
+ DestinationReserveFraction: 0.75f));
+ controller.ForceReloadWindow();
+
+ int priorLoaded = ids.Length;
+ for (int frame = 0; frame < 32 && priorLoaded != 0; frame++)
+ {
+ controller.Tick(0x23, 0x23);
+ int loaded = ids.Count(state.IsLoaded);
+ Assert.InRange(priorLoaded - loaded, 0, 1);
+ Assert.InRange(
+ controller.WorkDiagnostics.LastFrame.Used.EntityOperations,
+ 0,
+ 1);
+ priorLoaded = loaded;
+ }
+
+ Assert.Equal(0, priorLoaded);
+ Assert.Equal(ids.Length, controller.LastFullWindowRetirementLandblockCount);
+ }
+
[Fact]
public void ReplacementPublication_WaitsForSameIdRetirementFence()
{
@@ -126,12 +278,18 @@ public sealed class LandblockRetirementCoordinatorTests
EmptyMesh(),
generation: 1));
- controller.Tick(0x32, 0x32); // second failure; publication stays deferred
+ controller.Tick(0x32, 0x32); // first failure; publication stays at worker source
Assert.Equal(0, applyCount);
Assert.False(state.IsLoaded(landblockId));
Assert.True(coordinator.IsPending(landblockId));
- controller.Tick(0x32, 0x32); // third attempt completes, then publishes once
+ controller.Tick(0x32, 0x32); // second failure
+ Assert.Equal(0, applyCount);
+ Assert.True(coordinator.IsPending(landblockId));
+
+ controller.Tick(0x32, 0x32); // third attempt completes the reload barrier
+ Assert.Equal(0, applyCount);
+ controller.Tick(0x32, 0x32); // replacement publishes on the next frame
Assert.Equal(1, applyCount);
Assert.True(state.IsLoaded(landblockId));
Assert.False(coordinator.IsPending(landblockId));
@@ -338,7 +496,8 @@ public sealed class LandblockRetirementCoordinatorTests
GpuWorldState state,
LandblockRetirementCoordinator coordinator,
Func>? drain = null,
- Action? apply = null) =>
+ Action? apply = null,
+ StreamingWorkBudgetOptions? workBudgetOptions = null) =>
new(
enqueueLoad: (_, _, _) => { },
enqueueUnload: (_, _) => { },
@@ -347,7 +506,8 @@ public sealed class LandblockRetirementCoordinatorTests
state: state,
nearRadius: 0,
farRadius: 0,
- retirementCoordinator: coordinator);
+ retirementCoordinator: coordinator,
+ workBudgetOptions: workBudgetOptions);
private static IReadOnlyList Drain(
Queue pending,
@@ -359,6 +519,60 @@ public sealed class LandblockRetirementCoordinatorTests
return result;
}
+ private static LandblockRetirementOperationResult AdvancePresentationStep(
+ LandblockRetirementTicket ticket,
+ Action? lighting = null)
+ {
+ return ticket.NextIncompleteStage switch
+ {
+ LandblockRetirementStage.EntityLighting =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.EntityLighting,
+ static _ => true,
+ lighting ?? (_ => { })),
+ LandblockRetirementStage.EntityTranslucency =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.EntityTranslucency,
+ static _ => true,
+ static _ => { }),
+ LandblockRetirementStage.PluginProjection =>
+ ticket.RunEntityStep(
+ LandblockRetirementStage.PluginProjection,
+ static entity => entity.ServerGuid == 0,
+ static _ => { }),
+ LandblockRetirementStage.Terrain =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.Terrain,
+ static () => { }),
+ LandblockRetirementStage.Physics =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.Physics,
+ static () => { }),
+ LandblockRetirementStage.CellVisibility =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.CellVisibility,
+ static () => { }),
+ LandblockRetirementStage.BuildingRegistry =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.BuildingRegistry,
+ static () => { }),
+ LandblockRetirementStage.EnvironmentCells =>
+ ticket.RunOnceStep(
+ LandblockRetirementStage.EnvironmentCells,
+ static () => { }),
+ _ => LandblockRetirementOperationResult.NoWork,
+ };
+ }
+
+ private static StreamingWorkBudget Budget(int maxEntityOperations) => new(
+ maxUpdateTime: TimeSpan.FromSeconds(1),
+ maxCompletionAdmissions: 64,
+ maxAdoptedCpuBytes: 64 * 1024 * 1024,
+ maxEntityOperations,
+ maxGpuUploadBytes: 64 * 1024 * 1024,
+ maxGlRetireOperations: 64,
+ destinationReserveFraction: 0.75f);
+
private static void CompletePresentation(
LandblockRetirementTicket ticket,
Action? lighting = null,
diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
index cf06b74d..a4520daf 100644
--- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs
@@ -275,7 +275,7 @@ public sealed class LocalPlayerTeleportControllerTests
}
[Fact]
- public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence()
+ public void SessionReset_ClearsTransitAndLetsRecenterConvergeAcrossLaterFrames()
{
var harness = new Harness();
harness.Controller.OnTeleportStarted(12);
@@ -291,7 +291,9 @@ public sealed class LocalPlayerTeleportControllerTests
var failed = new Harness();
failed.Streaming.ResetResult = false;
- Assert.Throws(() => failed.Controller.ResetSession());
+ failed.Controller.ResetSession();
+ Assert.False(failed.Controller.IsActive);
+ Assert.True(failed.Streaming.LastResetWasSessionEnding);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs
new file mode 100644
index 00000000..7af26848
--- /dev/null
+++ b/tests/AcDream.App.Tests/Streaming/WorldGenerationQuiescenceTests.cs
@@ -0,0 +1,123 @@
+using System.Numerics;
+using AcDream.App.Audio;
+using AcDream.App.Streaming;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.Tests.Streaming;
+
+public sealed class WorldGenerationQuiescenceTests
+{
+ private const uint LandblockId = 0x1010FFFFu;
+ private const uint ServerGuid = 0x70000001u;
+
+ [Fact]
+ public void ActiveGeneration_HidesWorldQueriesButRetainsCanonicalOwners()
+ {
+ var availability = new WorldGenerationAvailabilityState();
+ var world = new GpuWorldState(availability: availability);
+ WorldEntity entity = Entity();
+ world.AddLandblock(new LoadedLandblock(
+ LandblockId,
+ new LandBlock(),
+ Array.Empty()));
+ world.PlaceLiveEntityProjection(LandblockId, entity);
+ world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One);
+ var nearby = new List>();
+
+ Assert.True(world.IsLiveEntityVisible(ServerGuid));
+ Assert.Single(world.LandblockEntries);
+ Assert.Single(world.LandblockBounds);
+
+ availability.Begin(17);
+ world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby);
+
+ Assert.False(availability.IsWorldAvailable);
+ Assert.False(world.IsLiveEntityVisible(ServerGuid));
+ Assert.Empty(world.LandblockEntries);
+ Assert.Empty(world.LandblockBounds);
+ Assert.Empty(nearby);
+ Assert.Same(entity, Assert.Single(world.Entities));
+ Assert.True(world.IsLoaded(LandblockId));
+
+ Assert.False(availability.End(16));
+ Assert.False(availability.IsWorldAvailable);
+ Assert.True(availability.End(17));
+ Assert.True(world.IsLiveEntityVisible(ServerGuid));
+ Assert.Same(entity, Assert.Single(world.LandblockEntries).Entities.Single());
+ }
+
+ [Fact]
+ public void SupersededReveal_StaysContinuouslySilentAndOnlyLatestGenerationReopens()
+ {
+ var availability = new WorldGenerationAvailabilityState();
+ var world = new GpuWorldState(availability: availability);
+ world.AddLandblock(new LoadedLandblock(
+ LandblockId,
+ new LandBlock(),
+ Array.Empty()));
+ world.PlaceLiveEntityProjection(LandblockId, Entity());
+ var selection = new SelectionState();
+ selection.Select(ServerGuid, SelectionChangeSource.World);
+ var audio = new RecordingAudioQuiescence();
+ var quiescence = new WorldGenerationQuiescence(
+ availability,
+ selection,
+ world,
+ audio);
+
+ quiescence.Begin(1);
+ quiescence.Begin(2);
+ quiescence.End(1);
+
+ Assert.False(availability.IsWorldAvailable);
+ Assert.Equal(2, availability.QuiescedGeneration);
+ Assert.Null(selection.SelectedObjectId);
+ Assert.Equal(1, audio.SuspendCalls);
+ Assert.Equal(0, audio.ResumeCalls);
+
+ quiescence.End(2);
+
+ Assert.True(availability.IsWorldAvailable);
+ Assert.Equal(1, audio.ResumeCalls);
+ }
+
+ [Fact]
+ public void Quiesce_PreservesNonWorldInventorySelection()
+ {
+ const uint inventoryGuid = 0x80000001u;
+ var availability = new WorldGenerationAvailabilityState();
+ var world = new GpuWorldState(availability: availability);
+ var selection = new SelectionState();
+ selection.Select(inventoryGuid, SelectionChangeSource.Inventory);
+ var quiescence = new WorldGenerationQuiescence(
+ availability,
+ selection,
+ world,
+ audio: null);
+
+ quiescence.Begin(3);
+
+ Assert.Equal(inventoryGuid, selection.SelectedObjectId);
+ }
+
+ private static WorldEntity Entity() => new()
+ {
+ Id = 1,
+ ServerGuid = ServerGuid,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = Array.Empty(),
+ };
+
+ private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence
+ {
+ public int SuspendCalls { get; private set; }
+ public int ResumeCalls { get; private set; }
+
+ public void SuspendWorldAudio() => SuspendCalls++;
+ public void ResumeWorldAudio() => ResumeCalls++;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs
index 8ddc0791..53335367 100644
--- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs
+++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs
@@ -1,4 +1,5 @@
using AcDream.App.Streaming;
+using AcDream.Core.Selection;
namespace AcDream.App.Tests.Streaming;
@@ -114,4 +115,46 @@ public sealed class WorldRevealCoordinatorTests
Assert.Equal(default, coordinator.Snapshot.Readiness);
Assert.Equal(0, coordinator.PortalMaterializationCount);
}
+
+ [Fact]
+ public void RevealLifetime_QuiescesContinuouslyAndOnlyActiveGenerationReopens()
+ {
+ var availability = new WorldGenerationAvailabilityState();
+ var world = new GpuWorldState(availability: availability);
+ var audio = new RecordingAudioQuiescence();
+ var quiescence = new WorldGenerationQuiescence(
+ availability,
+ new SelectionState(),
+ world,
+ audio);
+ var coordinator = new WorldRevealCoordinator(
+ isRenderNeighborhoodReady: (_, _) => true,
+ isSpawnCellReady: _ => true,
+ isTerrainNeighborhoodReady: (_, _) => true,
+ areCompositeTexturesReady: () => true,
+ prepareCompositeTextures: (_, _) => { },
+ invalidateCompositeTextures: () => { },
+ isSpawnClaimUnhydratable: _ => false,
+ quiescence: quiescence);
+
+ Assert.Equal(1, coordinator.Begin(WorldRevealKind.Login));
+ Assert.Equal(2, coordinator.Begin(WorldRevealKind.Portal));
+ Assert.False(availability.IsWorldAvailable);
+ Assert.Equal(2, availability.QuiescedGeneration);
+ Assert.Equal(1, audio.SuspendCalls);
+
+ coordinator.Complete();
+
+ Assert.True(availability.IsWorldAvailable);
+ Assert.Equal(1, audio.ResumeCalls);
+ }
+
+ private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence
+ {
+ public int SuspendCalls { get; private set; }
+ public int ResumeCalls { get; private set; }
+
+ public void SuspendWorldAudio() => SuspendCalls++;
+ public void ResumeWorldAudio() => ResumeCalls++;
+ }
}
diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
index a57ae719..f65f50ee 100644
--- a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
+++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
@@ -38,6 +38,25 @@ public sealed class RecallTeleportAnimationTests
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
}
+ [Fact]
+ public void LiveFrame_WhileCellsAreBlocked_DrainsNetworkAndCommandsWithoutWorldTicks()
+ {
+ var calls = new List();
+ var availability = new WorldGenerationAvailabilityState();
+ availability.Begin(1);
+ var frame = new RetailLiveFrameCoordinator(
+ new TestLiveObjectFramePhase(_ => calls.Add("objects")),
+ new GpuWorldState(availability: availability),
+ new TestLiveSessionFramePhase(() => calls.Add("network")),
+ new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
+ new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")),
+ availability);
+
+ frame.Tick(1f / 60f);
+
+ Assert.Equal(["network", "commands"], calls);
+ }
+
[Fact]
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index 27a25f4b..6388c06a 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using AcDream.App.Rendering;
+using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
@@ -129,6 +130,33 @@ public sealed class UpdateFrameOrchestratorTests
observed.Camera.Select(value => value.SimulationDeltaSeconds));
}
+ [Fact]
+ public void QuiescedWorld_FreezesScriptClockAndLivenessWhilePortalPhasesContinue()
+ {
+ var calls = new List();
+ var observed = new FrameObservations();
+ var availability = new WorldGenerationAvailabilityState();
+ availability.Begin(7);
+ UpdateFrameOrchestrator frame = Create(
+ calls,
+ observed: observed,
+ availability: availability);
+
+ frame.Tick(new UpdateFrameInput(0.25));
+ frame.Tick(new UpdateFrameInput(0.25));
+ availability.End(7);
+ frame.Tick(new UpdateFrameInput(0.25));
+
+ Assert.Equal([0.0, 0.0, 0.25], observed.PublishedTimes);
+ Assert.Equal(1, calls.Count(call => call == "liveness"));
+ Assert.Equal(3, calls.Count(call => call == "streaming"));
+ Assert.Equal(3, calls.Count(call => call == "network"));
+ Assert.Equal(3, calls.Count(call => call == "teleport"));
+ Assert.Equal(3, calls.Count(call => call == "camera"));
+ Assert.Equal([0.25f, 0.25f, 0.25f], observed.LiveDeltas);
+ Assert.Equal([0.25f, 0.25f, 0.25f], observed.TeleportDeltas);
+ }
+
[Fact]
public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange()
{
@@ -200,6 +228,7 @@ public sealed class UpdateFrameOrchestratorTests
typeof(ILocalPlayerTeleportFramePhase),
typeof(IPlayerModeAutoEntryFramePhase),
typeof(ICameraFramePhase),
+ typeof(IWorldGenerationAvailability),
];
FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields(
@@ -534,7 +563,8 @@ public sealed class UpdateFrameOrchestratorTests
"new LiveEntityLivenessFramePhase(",
"session.LocalTeleport,",
"new PlayerModeAutoEntryFramePhase(",
- "cameraFrame);");
+ "cameraFrame,",
+ "live.WorldAvailability);");
}
[Fact]
@@ -860,7 +890,8 @@ public sealed class UpdateFrameOrchestratorTests
RecordingFailureSink? failureSink = null,
FrameObservations? observed = null,
bool teleportPlace = false,
- bool inboundCreatedPlayer = false)
+ bool inboundCreatedPlayer = false,
+ IWorldGenerationAvailability? availability = null)
{
teardown ??= new RecordingTeardown(calls);
failureSink ??= new RecordingFailureSink();
@@ -875,7 +906,8 @@ public sealed class UpdateFrameOrchestratorTests
new RecordingLiveness(calls),
new RecordingTeleport(calls, observed, teleportPlace),
new RecordingAutoEntry(calls),
- new RecordingCamera(calls, observed, inboundCreatedPlayer));
+ new RecordingCamera(calls, observed, inboundCreatedPlayer),
+ availability);
}
private sealed class RecordingTeardown(List calls)
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs
index 2a183ecc..5c735bf8 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs
@@ -543,7 +543,8 @@ public class StreamingControllerPriorityApplyTests
ctrl.ForceReloadWindow();
loads.Clear();
- ctrl.Tick(11, 10); // overlap remains desired in the replacement window.
+ for (int i = 0; i < 32 && !loads.Any(load => load.Id == overlap); i++)
+ ctrl.Tick(11, 10);
ulong newGeneration = loads.Single(load => load.Id == overlap).Generation;
Assert.NotEqual(oldGeneration, newGeneration);
@@ -551,7 +552,8 @@ public class StreamingControllerPriorityApplyTests
outbox.Enqueue(LoadedOf(overlap, newGeneration));
outbox.Enqueue(new LandblockStreamResult.Unloaded(overlap, oldGeneration));
- ctrl.Tick(11, 10);
+ for (int i = 0; i < 8 && applied.Count == 0; i++)
+ ctrl.Tick(11, 10);
Assert.Equal(new[] { overlap }, applied);
Assert.True(state.IsLoaded(overlap));
@@ -559,7 +561,7 @@ public class StreamingControllerPriorityApplyTests
}
[Fact]
- public void HardRecenter_DropsStaleOutboxBeforeDeferredApplyBudget()
+ public void HardRecenter_DropsStaleOutboxThroughBoundedAdmissionBeforeCurrentResult()
{
uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10);
var outbox = new Queue();
@@ -584,7 +586,8 @@ public class StreamingControllerPriorityApplyTests
ulong oldGeneration = loads[0].Generation;
ctrl.ForceReloadWindow();
loads.Clear();
- ctrl.Tick(11, 10);
+ for (int i = 0; i < 32 && !loads.Any(load => load.Id == current); i++)
+ ctrl.Tick(11, 10);
ulong newGeneration = loads.Single(load => load.Id == current).Generation;
for (int i = 0; i < 12; i++)
@@ -593,7 +596,8 @@ public class StreamingControllerPriorityApplyTests
oldGeneration));
outbox.Enqueue(LoadedOf(current, newGeneration));
- ctrl.Tick(11, 10);
+ for (int i = 0; i < 32 && !applied.Contains(current); i++)
+ ctrl.Tick(11, 10);
Assert.Equal(new[] { current }, applied);
Assert.Equal(0, ctrl.DeferredApplyBacklog);