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);