perf(streaming): quiesce retired generations and budget teardown
Publish the retail blocking-for-cells edge before deferred recenter work, freeze old-world presentation/simulation/audio, and advance full-window retirement from exact metered entity and owner cursors. This removes synchronous portal teardown without allowing retained owners to remain observable.
This commit is contained in:
parent
b8f6317fe1
commit
bb16f74fd4
38 changed files with 1691 additions and 170 deletions
23
AGENTS.md
23
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
|
Release builds; the appraisal formula-media/foreground correction passes
|
||||||
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
|
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
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
spell-bar overflow, status Use/Assess, assessment information,
|
spell-bar overflow, status Use/Assess, assessment information,
|
||||||
equipped-child picking, vendor browsing, and authoritative vendor
|
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
|
examination are implemented; the current visual gate is its corrected modern
|
||||||
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
|
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
|
||||||
|
|
||||||
**Active performance side track:** Slices A–C of
|
**Current active engineering work:** complete Slices D–L of
|
||||||
`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
|
`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
|
Production world-mesh streaming now consumes the validated machine-local
|
||||||
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
|
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
|
||||||
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
||||||
lifetime, full-suite, and user-visual gates passed. Process allocation fell
|
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
|
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
|
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
|
||||||
handles and unified residency accounting) is next; ECS/MP3 remains behind its
|
typed residency policy/accounting and bounded retained caches. Slice E0–E3 now
|
||||||
explicit user decision gate. Evidence:
|
enforce typed completion admission, generation-scoped old-world quiescence,
|
||||||
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
|
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
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
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.
|
truth remain correct.
|
||||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||||
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
|
`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.
|
lifestone/particle alpha visual gate.
|
||||||
|
|
||||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||||
|
|
|
||||||
23
CLAUDE.md
23
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
|
Release builds; the appraisal formula-media/foreground correction passes
|
||||||
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
|
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
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
spell-bar overflow, status Use/Assess, assessment information,
|
spell-bar overflow, status Use/Assess, assessment information,
|
||||||
equipped-child picking, vendor browsing, and authoritative vendor
|
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
|
examination are implemented; the current visual gate is its corrected modern
|
||||||
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
|
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
|
||||||
|
|
||||||
**Active performance side track:** Slices A–C of
|
**Current active engineering work:** complete Slices D–L of
|
||||||
`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
|
`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
|
Production world-mesh streaming now consumes the validated machine-local
|
||||||
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
|
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
|
||||||
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
|
||||||
lifetime, full-suite, and user-visual gates passed. Process allocation fell
|
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
|
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
|
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
|
||||||
handles and unified residency accounting) is next; ECS/MP3 remains behind its
|
typed residency policy/accounting and bounded retained caches. Slice E0–E3 now
|
||||||
explicit user decision gate. Evidence:
|
enforce typed completion admission, generation-scoped old-world quiescence,
|
||||||
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
|
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
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
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.
|
truth remain correct.
|
||||||
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
|
||||||
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
|
`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.
|
lifestone/particle alpha visual gate.
|
||||||
|
|
||||||
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
Start structural work at `memory/project_gamewindow_decomposition.md` and
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,11 @@ What does NOT go here:
|
||||||
smooth and physics, collision, and wire state remain correct.
|
smooth and physics, collision, and wire state remain correct.
|
||||||
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
|
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
|
||||||
production compilation and all tests pass.
|
production compilation and all tests pass.
|
||||||
- **Performance side track:** prepared-content Slices A–C and unified-residency
|
- **Performance side track:** Slices A–D and Slice E0–E3 are complete. Typed
|
||||||
Slice D are complete. Slice E cost-budgeted streaming/retirement is next.
|
completion admission, immediate generation-scoped old-world quiescence, and
|
||||||
Evidence:
|
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).
|
[`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
|
The [documentation map](README.md) defines how this tactical ledger relates to
|
||||||
|
|
|
||||||
|
|
@ -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
|
- **Milestone state:** M3, “Cast a spell,” landed 2026-07-21. M4, “Live in the
|
||||||
world,” is active.
|
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
|
in
|
||||||
[`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md).
|
[`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
|
The eight-slice `GameWindow` decomposition and its connected visual matrix
|
||||||
are complete.
|
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)
|
[`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
|
are complete. Production world-mesh streaming consumes the validated
|
||||||
prepared package; Slice D unified residency/accounting is next. See the
|
prepared package, typed residency is bounded/accounted, and streaming now
|
||||||
[Slice C physical cutover report](research/2026-07-24-slice-c-prepared-asset-cutover-report.md).
|
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
|
- **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and
|
||||||
the final two-client portal-out/materialization observer comparison (`#218`).
|
the final two-client portal-out/materialization observer comparison (`#218`).
|
||||||
- **Separate visual verification:** the shared-alpha lifestone/particle result
|
- **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.
|
user decision gate; Linux/headless automation (Track LH) remains future work.
|
||||||
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
|
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
|
||||||
and UN 5 — plus the retained retired TS-37 history note.
|
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
|
five are intentionally skipped. The capped, uncapped, and dense-Arwic
|
||||||
physical prepared-asset routes all completed their portal/input/lifetime
|
physical prepared-asset routes all completed their portal/input/lifetime
|
||||||
assertions and disconnected gracefully.
|
assertions and disconnected gracefully.
|
||||||
|
|
|
||||||
|
|
@ -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
|
executes every class through the same meter. Priority changes order, never the
|
||||||
budget. Lifecycle artifacts publish worker and per-class backlog, retained
|
budget. Lifecycle artifacts publish worker and per-class backlog, retained
|
||||||
bytes/age, yields, oversizes, overruns, and pending publication/retirement
|
bytes/age, yields, oversizes, overruns, and pending publication/retirement
|
||||||
facts. Publication remains transaction-atomic until Slice E4; retirement
|
facts.
|
||||||
quiesce/cursors belong to E3.
|
|
||||||
|
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
|
### World-reveal readiness ownership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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-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) |
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -1605,13 +1605,16 @@ port in any phase — no separate listing here.
|
||||||
> and dense-Arwic physical routes passed; third-visit cache residence was
|
> and dense-Arwic physical routes passed; third-visit cache residence was
|
||||||
> non-growing and no checkpoint retained staged or retiring bytes. [Residency
|
> non-growing and no checkpoint retained staged or retiring bytes. [Residency
|
||||||
> report](../research/2026-07-24-slice-d-unified-residency-report.md). Slice E
|
> 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
|
> deferred list, count-only throttle, priority hunt, and unload bypass with one
|
||||||
> typed-meter scheduler over explicit stable destination/control/unload/Near/
|
> typed-meter scheduler over explicit stable destination/control/unload/Near/
|
||||||
> Far queues; production prices worker results before adoption and lifecycle
|
> Far queues; production prices worker results before adoption and lifecycle
|
||||||
> artifacts expose exact backlog/retained-byte facts. E3 next adds immediate
|
> artifacts expose exact backlog/retained-byte facts. E3 now makes the old
|
||||||
> old-generation quiesce and cursored retirement. F/G and J retain their
|
> generation unavailable at the hard reveal edge and freezes its world
|
||||||
> explicit approval gates.
|
> 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
|
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
|
||||||
umbrella design — read it first). **Goal:** smoothness first (no frame over
|
umbrella design — read it first). **Goal:** smoothness first (no frame over
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,12 @@ The separately authorized modern-runtime performance program has completed
|
||||||
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
Slices A–D: corrected measurement, prepared-package bake/dedup, package-only
|
||||||
production streaming, and typed unified residency. Its physical capped,
|
production streaming, and typed unified residency. Its physical capped,
|
||||||
uncapped, and dense connected gates pass without a visual-quality change.
|
uncapped, and dense connected gates pass without a visual-quality change.
|
||||||
Slice E0–E2 now define and enforce typed completion admission through explicit
|
Slice E0–E3 now define and enforce typed completion admission through explicit
|
||||||
stable priority queues; E3 adds immediate old-generation quiesce and cursored
|
stable priority queues, publish an immediate generation-scoped old-world
|
||||||
retirement next. Slices F/G remain behind their explicit ECS approval gate,
|
quiesce at login/portal boundaries, and advance full-window/recenter teardown
|
||||||
and Slice J remains behind its gameplay-owner approval gate.
|
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
|
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's
|
||||||
status hand selected-object availability and existing
|
status hand selected-object availability and existing
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Date:** 2026-07-24
|
**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
|
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
|
||||||
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
|
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Modern Runtime Slice E — Cost-budgeted streaming and retirement
|
# 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`
|
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
|
||||||
**Baseline:** Slice D closeout commit `66690805`
|
**Baseline:** Slice D closeout commit `66690805`
|
||||||
**Behavior contract:** no visual-quality or retail-behavior reduction
|
**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
|
- Prove no old generation ticks, emits, collides, targets, or renders while its
|
||||||
retained memory converges.
|
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
|
### E4 — Cursor-budgeted publication
|
||||||
|
|
||||||
- Move stable ordered arrays/cost facts into retained receipts.
|
- Move stable ordered arrays/cost facts into retained receipts.
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,14 @@ public sealed class AudioHookSink : IAnimationHookSink
|
||||||
if (waveId == 0) return;
|
if (waveId == 0) return;
|
||||||
WaveData? wave = _cache.GetWave(waveId);
|
WaveData? wave = _cache.GetWave(waveId);
|
||||||
if (wave is null) return;
|
if (wave is null) return;
|
||||||
_engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
|
_engine.Play3DWave(
|
||||||
|
entityId,
|
||||||
|
waveId,
|
||||||
|
wave,
|
||||||
|
worldPos,
|
||||||
|
volume,
|
||||||
|
priority,
|
||||||
|
pitch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,13 @@ namespace AcDream.App.Audio;
|
||||||
/// unaffected.
|
/// unaffected.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
internal interface IWorldAudioQuiescence
|
||||||
|
{
|
||||||
|
void SuspendWorldAudio();
|
||||||
|
void ResumeWorldAudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
|
||||||
{
|
{
|
||||||
// ── Backends ─────────────────────────────────────────────────────────────
|
// ── Backends ─────────────────────────────────────────────────────────────
|
||||||
private AL? _al;
|
private AL? _al;
|
||||||
|
|
@ -71,12 +77,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
private sealed class Slot3D
|
private sealed class Slot3D
|
||||||
{
|
{
|
||||||
public uint SourceId;
|
public uint SourceId;
|
||||||
|
public uint OwnerId;
|
||||||
public float PlayingGain; // gain at play time (for eviction compare)
|
public float PlayingGain; // gain at play time (for eviction compare)
|
||||||
public bool InUse;
|
public bool InUse;
|
||||||
public uint PriorityBase; // raw priority from SoundEntry.Priority
|
public uint PriorityBase; // raw priority from SoundEntry.Priority
|
||||||
}
|
}
|
||||||
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
|
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
|
||||||
private int _pool3DCursor; // round-robin start
|
private int _pool3DCursor; // round-robin start
|
||||||
|
private bool _worldAudioSuspended;
|
||||||
|
|
||||||
private readonly uint[] _poolUi = new uint[PoolSizeUi];
|
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.
|
/// Returns true on success, false if the buffer was rejected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Play3DWave(
|
public bool Play3DWave(
|
||||||
|
uint ownerId,
|
||||||
uint waveId,
|
uint waveId,
|
||||||
WaveData wave,
|
WaveData wave,
|
||||||
Vector3 position,
|
Vector3 position,
|
||||||
|
|
@ -245,7 +254,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
float priority,
|
float priority,
|
||||||
float pitch = 1.0f)
|
float pitch = 1.0f)
|
||||||
{
|
{
|
||||||
if (!_available || _al is null) return false;
|
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||||
|
|
||||||
float effectiveGain = volume * SfxVolume;
|
float effectiveGain = volume * SfxVolume;
|
||||||
if (effectiveGain < 0.001f) return false; // silent; skip
|
if (effectiveGain < 0.001f) return false; // silent; skip
|
||||||
|
|
@ -284,11 +293,39 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
|
|
||||||
slot.PlayingGain = effectiveGain;
|
slot.PlayingGain = effectiveGain;
|
||||||
slot.InUse = true;
|
slot.InUse = true;
|
||||||
|
slot.OwnerId = ownerId;
|
||||||
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
|
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
|
||||||
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
|
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
|
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
|
||||||
/// listener position).
|
/// listener position).
|
||||||
|
|
@ -469,4 +506,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
|
||||||
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
|
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
|
||||||
return state == (int)SourceState.Playing;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
retailPViewCells,
|
retailPViewCells,
|
||||||
worldScenePasses,
|
worldScenePasses,
|
||||||
d.RenderRange,
|
d.RenderRange,
|
||||||
worldSceneDiagnostics);
|
worldSceneDiagnostics,
|
||||||
|
live.WorldAvailability);
|
||||||
Fault(FrameRootCompositionPoint.WorldRendererCreated);
|
Fault(FrameRootCompositionPoint.WorldRendererCreated);
|
||||||
|
|
||||||
bindings = new FrameRootRuntimeBindings();
|
bindings = new FrameRootRuntimeBindings();
|
||||||
|
|
@ -469,7 +470,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
live.WorldState,
|
live.WorldState,
|
||||||
session.LiveSession,
|
session.LiveSession,
|
||||||
session.LocalPlayerFrame,
|
session.LocalPlayerFrame,
|
||||||
session.LiveSpatialReconciler);
|
session.LiveSpatialReconciler,
|
||||||
|
live.WorldAvailability);
|
||||||
var cameraFrame = new CameraFrameController(
|
var cameraFrame = new CameraFrameController(
|
||||||
host.CameraController,
|
host.CameraController,
|
||||||
d.InputCapture,
|
d.InputCapture,
|
||||||
|
|
@ -496,7 +498,8 @@ internal sealed class FrameRootCompositionPhase
|
||||||
new StopwatchClientMonotonicTimeSource()),
|
new StopwatchClientMonotonicTimeSource()),
|
||||||
session.LocalTeleport,
|
session.LocalTeleport,
|
||||||
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
|
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
|
||||||
cameraFrame);
|
cameraFrame,
|
||||||
|
live.WorldAvailability);
|
||||||
Fault(FrameRootCompositionPoint.UpdateRootCreated);
|
Fault(FrameRootCompositionPoint.UpdateRootCreated);
|
||||||
|
|
||||||
var bindingsLease = scope.Own(
|
var bindingsLease = scope.Own(
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ internal sealed record LivePresentationResult(
|
||||||
EntitySpawnAdapter EntitySpawnAdapter,
|
EntitySpawnAdapter EntitySpawnAdapter,
|
||||||
EntityScriptActivator EntityScriptActivator,
|
EntityScriptActivator EntityScriptActivator,
|
||||||
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
|
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
|
||||||
|
WorldGenerationAvailabilityState WorldAvailability,
|
||||||
GpuWorldState WorldState,
|
GpuWorldState WorldState,
|
||||||
LiveEntityRuntime LiveEntities,
|
LiveEntityRuntime LiveEntities,
|
||||||
ProjectileController ProjectileController,
|
ProjectileController ProjectileController,
|
||||||
|
|
@ -319,10 +320,12 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
staticAnimationScheduler.Unregister,
|
staticAnimationScheduler.Unregister,
|
||||||
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
|
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
|
||||||
|
|
||||||
|
var worldAvailability = new WorldGenerationAvailabilityState();
|
||||||
var worldState = new GpuWorldState(
|
var worldState = new GpuWorldState(
|
||||||
wbSpawnAdapter,
|
wbSpawnAdapter,
|
||||||
d.ClassificationCache.InvalidateLandblock,
|
d.ClassificationCache.InvalidateLandblock,
|
||||||
entityScriptActivator);
|
entityScriptActivator,
|
||||||
|
worldAvailability);
|
||||||
bindings = new LivePresentationRuntimeBindings();
|
bindings = new LivePresentationRuntimeBindings();
|
||||||
if (d.DevWorldEntities is { } devWorldEntities)
|
if (d.DevWorldEntities is { } devWorldEntities)
|
||||||
{
|
{
|
||||||
|
|
@ -524,6 +527,7 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
entitySpawnAdapter,
|
entitySpawnAdapter,
|
||||||
entityScriptActivator,
|
entityScriptActivator,
|
||||||
staticAnimationScheduler,
|
staticAnimationScheduler,
|
||||||
|
worldAvailability,
|
||||||
worldState,
|
worldState,
|
||||||
liveEntities,
|
liveEntities,
|
||||||
projectileController,
|
projectileController,
|
||||||
|
|
@ -568,6 +572,7 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
EntitySpawnAdapter entitySpawnAdapter,
|
EntitySpawnAdapter entitySpawnAdapter,
|
||||||
EntityScriptActivator entityScriptActivator,
|
EntityScriptActivator entityScriptActivator,
|
||||||
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
|
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
|
||||||
|
WorldGenerationAvailabilityState worldAvailability,
|
||||||
GpuWorldState worldState,
|
GpuWorldState worldState,
|
||||||
LiveEntityRuntime liveEntities,
|
LiveEntityRuntime liveEntities,
|
||||||
ProjectileController projectileController,
|
ProjectileController projectileController,
|
||||||
|
|
@ -942,6 +947,7 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
entitySpawnAdapter,
|
entitySpawnAdapter,
|
||||||
entityScriptActivator,
|
entityScriptActivator,
|
||||||
staticAnimationScheduler,
|
staticAnimationScheduler,
|
||||||
|
worldAvailability,
|
||||||
worldState,
|
worldState,
|
||||||
liveEntities,
|
liveEntities,
|
||||||
projectileController,
|
projectileController,
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,11 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
|
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
|
||||||
content.Dats,
|
content.Dats,
|
||||||
d.DatLock);
|
d.DatLock);
|
||||||
|
var worldQuiescence = new WorldGenerationQuiescence(
|
||||||
|
live.WorldAvailability,
|
||||||
|
d.Selection,
|
||||||
|
live.WorldState,
|
||||||
|
content.Audio?.Engine);
|
||||||
var worldReveal = new WorldRevealCoordinator(
|
var worldReveal = new WorldRevealCoordinator(
|
||||||
streaming.IsRenderNeighborhoodResident,
|
streaming.IsRenderNeighborhoodResident,
|
||||||
d.PhysicsEngine.IsSpawnCellReady,
|
d.PhysicsEngine.IsSpawnCellReady,
|
||||||
|
|
@ -333,7 +338,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
radius),
|
radius),
|
||||||
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
|
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
|
||||||
spawnClaimClassifier.IsUnhydratable,
|
spawnClaimClassifier.IsUnhydratable,
|
||||||
d.Log);
|
d.Log,
|
||||||
|
worldQuiescence);
|
||||||
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
|
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
|
||||||
|
|
||||||
return CompleteSessionPlayer(
|
return CompleteSessionPlayer(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using AcDream.App.Rendering.Selection;
|
using AcDream.App.Rendering.Selection;
|
||||||
using AcDream.App.Rendering.Vfx;
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
@ -59,6 +60,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
|
||||||
private readonly IWorldScenePassExecutor _passes;
|
private readonly IWorldScenePassExecutor _passes;
|
||||||
private readonly IWorldRenderRangeSource _renderRange;
|
private readonly IWorldRenderRangeSource _renderRange;
|
||||||
private readonly IWorldSceneDiagnostics _diagnostics;
|
private readonly IWorldSceneDiagnostics _diagnostics;
|
||||||
|
private readonly IWorldGenerationAvailability _availability;
|
||||||
|
|
||||||
public WorldSceneRenderer(
|
public WorldSceneRenderer(
|
||||||
IRenderFrameFoundationSource foundation,
|
IRenderFrameFoundationSource foundation,
|
||||||
|
|
@ -73,7 +75,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
|
||||||
IRetailPViewCellSource pviewCells,
|
IRetailPViewCellSource pviewCells,
|
||||||
IWorldScenePassExecutor passes,
|
IWorldScenePassExecutor passes,
|
||||||
IWorldRenderRangeSource renderRange,
|
IWorldRenderRangeSource renderRange,
|
||||||
IWorldSceneDiagnostics diagnostics)
|
IWorldSceneDiagnostics diagnostics,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
|
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
|
||||||
_login = login ?? throw new ArgumentNullException(nameof(login));
|
_login = login ?? throw new ArgumentNullException(nameof(login));
|
||||||
|
|
@ -89,6 +92,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
|
||||||
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
|
||||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||||
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
public WorldRenderFrameOutcome Render(RenderFrameInput input)
|
||||||
|
|
@ -100,6 +104,13 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_selection?.BeginFrame();
|
_selection?.BeginFrame();
|
||||||
|
if (!_availability.IsWorldAvailable)
|
||||||
|
{
|
||||||
|
_selection?.CompleteFrame();
|
||||||
|
selectionFrameStarted = false;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
RenderFrameFoundation foundation = _foundation.Foundation;
|
RenderFrameFoundation foundation = _foundation.Foundation;
|
||||||
if (foundation.PortalViewportVisible)
|
if (foundation.PortalViewportVisible)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
{
|
{
|
||||||
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
||||||
private readonly EntityScriptActivator? _entityScriptActivator;
|
private readonly EntityScriptActivator? _entityScriptActivator;
|
||||||
|
private readonly IWorldGenerationAvailability _availability;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
|
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
|
||||||
|
|
@ -72,11 +73,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
public GpuWorldState(
|
public GpuWorldState(
|
||||||
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
||||||
System.Action<uint>? onLandblockUnloaded = null,
|
System.Action<uint>? onLandblockUnloaded = null,
|
||||||
EntityScriptActivator? entityScriptActivator = null)
|
EntityScriptActivator? entityScriptActivator = null,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
_wbSpawnAdapter = wbSpawnAdapter;
|
_wbSpawnAdapter = wbSpawnAdapter;
|
||||||
_onLandblockUnloaded = onLandblockUnloaded;
|
_onLandblockUnloaded = onLandblockUnloaded;
|
||||||
_entityScriptActivator = entityScriptActivator;
|
_entityScriptActivator = entityScriptActivator;
|
||||||
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||||
|
|
@ -151,7 +154,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
_loaded.ContainsKey(landblockId)
|
_loaded.ContainsKey(landblockId)
|
||||||
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
|
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
|
||||||
public bool IsLiveEntityVisible(uint serverGuid) =>
|
public bool IsLiveEntityVisible(uint serverGuid) =>
|
||||||
serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(serverGuid);
|
_availability.IsWorldAvailable
|
||||||
|
&& serverGuid != 0
|
||||||
|
&& _visibleLiveProjectionCounts.ContainsKey(serverGuid);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Try to grab the loaded record for a landblock — useful for callers
|
/// Try to grab the loaded record for a landblock — useful for callers
|
||||||
|
|
@ -217,6 +222,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (!_availability.IsWorldAvailable)
|
||||||
|
yield break;
|
||||||
|
|
||||||
foreach (var kvp in _loaded)
|
foreach (var kvp in _loaded)
|
||||||
{
|
{
|
||||||
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
|
||||||
|
|
@ -232,6 +240,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
|
||||||
bool includeAnimatedIndex)
|
bool includeAnimatedIndex)
|
||||||
{
|
{
|
||||||
|
if (!_availability.IsWorldAvailable)
|
||||||
|
yield break;
|
||||||
|
|
||||||
foreach (var kvp in _loaded)
|
foreach (var kvp in _loaded)
|
||||||
{
|
{
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
|
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
|
||||||
|
|
@ -299,6 +310,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
|
||||||
ArgumentNullException.ThrowIfNull(destination);
|
ArgumentNullException.ThrowIfNull(destination);
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
|
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
|
||||||
destination.Clear();
|
destination.Clear();
|
||||||
|
if (!_availability.IsWorldAvailable)
|
||||||
|
return;
|
||||||
|
|
||||||
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
|
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
|
||||||
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
|
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,9 @@ public sealed class LandblockPresentationPipeline
|
||||||
}
|
}
|
||||||
_onLandblockLoaded = onLandblockLoaded;
|
_onLandblockLoaded = onLandblockLoaded;
|
||||||
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
_ensureEnvCellMeshes = ensureEnvCellMeshes;
|
||||||
_retirements = new LandblockRetirementCoordinator(
|
_retirements = LandblockRetirementCoordinator.CreateBudgeted(
|
||||||
state,
|
state,
|
||||||
|
retirementOwner.AdvanceOne,
|
||||||
retirementOwner.Advance);
|
retirementOwner.Advance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +163,7 @@ public sealed class LandblockPresentationPipeline
|
||||||
_staticPublisher?.Diagnostics ?? default);
|
_staticPublisher?.Diagnostics ?? default);
|
||||||
|
|
||||||
public int PendingRetirementCount => _retirements.PendingCount;
|
public int PendingRetirementCount => _retirements.PendingCount;
|
||||||
|
internal bool UsesBudgetedRetirementSteps => _retirements.UsesBudgetedSteps;
|
||||||
public int PendingPublicationCount => _publications.Count;
|
public int PendingPublicationCount => _publications.Count;
|
||||||
|
|
||||||
internal bool MatchesState(GpuWorldState state) =>
|
internal bool MatchesState(GpuWorldState state) =>
|
||||||
|
|
@ -189,10 +191,27 @@ public sealed class LandblockPresentationPipeline
|
||||||
|
|
||||||
public void AdvanceRetirements() => _retirements.Advance();
|
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);
|
_retirements.BeginFull(landblockId);
|
||||||
|
|
||||||
public void BeginNearLayerRetirement(uint landblockId) =>
|
internal void EnqueueNearLayerRetirement(uint landblockId) =>
|
||||||
_retirements.BeginNearLayer(landblockId);
|
_retirements.BeginNearLayer(landblockId);
|
||||||
|
|
||||||
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
|
public void PublishLoaded(LandblockStreamResult.Loaded loaded)
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,58 @@ public sealed class LandblockPresentationRetirementOwner
|
||||||
() => _render.RemoveEnvironmentCells(ticket.LandblockId));
|
() => _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)
|
private void RemoveLighting(WorldEntity entity)
|
||||||
{
|
{
|
||||||
if (entity.ServerGuid == 0)
|
if (entity.ServerGuid == 0)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ public enum LandblockRetirementStage : ushort
|
||||||
LegacyPresentation = 1 << 11,
|
LegacyPresentation = 1 << 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal enum LandblockRetirementOperationResult : byte
|
||||||
|
{
|
||||||
|
NoWork,
|
||||||
|
Progressed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One retryable landblock-retirement ledger. Successful owner operations are
|
/// One retryable landblock-retirement ledger. Successful owner operations are
|
||||||
/// committed once; a later attempt resumes at the exact failed owner/entity.
|
/// 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(
|
public bool RunForEachEntity(
|
||||||
LandblockRetirementStage stage,
|
LandblockRetirementStage stage,
|
||||||
Func<WorldEntity, bool> predicate,
|
Func<WorldEntity, bool> predicate,
|
||||||
|
|
@ -109,6 +139,74 @@ public sealed class LandblockRetirementTicket
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal LandblockRetirementOperationResult RunEntityStep(
|
||||||
|
LandblockRetirementStage stage,
|
||||||
|
Func<WorldEntity, bool> predicate,
|
||||||
|
Action<WorldEntity> 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 BeginAttempt() => _callbackFailure = null;
|
||||||
|
|
||||||
internal void RecordCallbackFailure(Exception error) =>
|
internal void RecordCallbackFailure(Exception error) =>
|
||||||
|
|
@ -170,9 +268,13 @@ public sealed class LandblockRetirementCoordinator
|
||||||
|
|
||||||
private readonly GpuWorldState _state;
|
private readonly GpuWorldState _state;
|
||||||
private readonly Action<LandblockRetirementTicket> _advancePresentation;
|
private readonly Action<LandblockRetirementTicket> _advancePresentation;
|
||||||
|
private readonly Func<
|
||||||
|
LandblockRetirementTicket,
|
||||||
|
LandblockRetirementOperationResult>? _advancePresentationStep;
|
||||||
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
|
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
|
||||||
_requiredPresentationStages;
|
_requiredPresentationStages;
|
||||||
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
|
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
|
||||||
|
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new();
|
||||||
private readonly List<uint> _completedIds = new();
|
private readonly List<uint> _completedIds = new();
|
||||||
private readonly Queue<Request> _requests = new();
|
private readonly Queue<Request> _requests = new();
|
||||||
private readonly HashSet<Request> _seenDuringDrain = new();
|
private readonly HashSet<Request> _seenDuringDrain = new();
|
||||||
|
|
@ -187,12 +289,30 @@ public sealed class LandblockRetirementCoordinator
|
||||||
ArgumentNullException.ThrowIfNull(advancePresentation);
|
ArgumentNullException.ThrowIfNull(advancePresentation);
|
||||||
_state = state;
|
_state = state;
|
||||||
_advancePresentation = advancePresentation;
|
_advancePresentation = advancePresentation;
|
||||||
|
_advancePresentationStep = null;
|
||||||
_requiredPresentationStages = requiredPresentationStages
|
_requiredPresentationStages = requiredPresentationStages
|
||||||
?? (kind => kind == LandblockRetirementKind.Full
|
?? (kind => kind == LandblockRetirementKind.Full
|
||||||
? ProductionPresentationStages
|
? ProductionPresentationStages
|
||||||
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
|
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LandblockRetirementCoordinator(
|
||||||
|
GpuWorldState state,
|
||||||
|
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
|
||||||
|
advancePresentationStep,
|
||||||
|
Action<LandblockRetirementTicket> advancePresentation,
|
||||||
|
Func<LandblockRetirementKind, LandblockRetirementStage>
|
||||||
|
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 int PendingCount { get; private set; }
|
||||||
|
|
||||||
public bool IsPending(uint landblockId) =>
|
public bool IsPending(uint landblockId) =>
|
||||||
|
|
@ -200,6 +320,7 @@ public sealed class LandblockRetirementCoordinator
|
||||||
|
|
||||||
internal bool MatchesState(GpuWorldState state) =>
|
internal bool MatchesState(GpuWorldState state) =>
|
||||||
ReferenceEquals(_state, state);
|
ReferenceEquals(_state, state);
|
||||||
|
internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
|
||||||
|
|
||||||
public void BeginFull(uint landblockId) => EnqueueRequest(
|
public void BeginFull(uint landblockId) => EnqueueRequest(
|
||||||
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
|
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
|
||||||
|
|
@ -210,8 +331,81 @@ public sealed class LandblockRetirementCoordinator
|
||||||
public void Advance() => EnqueueRequest(
|
public void Advance() => EnqueueRequest(
|
||||||
new Request(RequestKind.Advance, 0u));
|
new Request(RequestKind.Advance, 0u));
|
||||||
|
|
||||||
|
internal static LandblockRetirementCoordinator CreateBudgeted(
|
||||||
|
GpuWorldState state,
|
||||||
|
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
|
||||||
|
advancePresentationStep,
|
||||||
|
Action<LandblockRetirementTicket> advancePresentation)
|
||||||
|
{
|
||||||
|
return new LandblockRetirementCoordinator(
|
||||||
|
state,
|
||||||
|
advancePresentationStep,
|
||||||
|
advancePresentation,
|
||||||
|
kind => kind == LandblockRetirementKind.Full
|
||||||
|
? ProductionPresentationStages
|
||||||
|
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances stable FIFO retirement work under the caller's one frame meter.
|
||||||
|
/// Every reservation maps to exactly one entity cursor or owner stage.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private void AdvanceCore()
|
||||||
{
|
{
|
||||||
|
if (_advancePresentationStep is not null)
|
||||||
|
{
|
||||||
|
AdvanceBudgetedEagerAttempt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_pending.Count == 0)
|
if (_pending.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -278,6 +472,9 @@ public sealed class LandblockRetirementCoordinator
|
||||||
if (existing[i].Kind == kind)
|
if (existing[i].Kind == kind)
|
||||||
{
|
{
|
||||||
LandblockRetirementTicket existingTicket = existing[i];
|
LandblockRetirementTicket existingTicket = existing[i];
|
||||||
|
if (_advancePresentationStep is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
AdvanceTicket(existingTicket);
|
AdvanceTicket(existingTicket);
|
||||||
if (existingTicket.IsComplete)
|
if (existingTicket.IsComplete)
|
||||||
{
|
{
|
||||||
|
|
@ -331,14 +528,19 @@ public sealed class LandblockRetirementCoordinator
|
||||||
}
|
}
|
||||||
existing.Add(ticket);
|
existing.Add(ticket);
|
||||||
PendingCount++;
|
PendingCount++;
|
||||||
|
if (_advancePresentationStep is not null)
|
||||||
|
_pendingOrder.Enqueue(ticket);
|
||||||
|
|
||||||
AdvanceTicket(ticket);
|
if (_advancePresentationStep is null)
|
||||||
if (ticket.IsComplete)
|
|
||||||
{
|
{
|
||||||
existing.Remove(ticket);
|
AdvanceTicket(ticket);
|
||||||
PendingCount--;
|
if (ticket.IsComplete)
|
||||||
if (existing.Count == 0)
|
{
|
||||||
_pending.Remove(canonical);
|
existing.Remove(ticket);
|
||||||
|
PendingCount--;
|
||||||
|
if (existing.Count == 0)
|
||||||
|
_pending.Remove(canonical);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Visibility is a delivered-by-attempt broadcast: every subscriber is
|
// Visibility is a delivered-by-attempt broadcast: every subscriber is
|
||||||
// invoked and failures are aggregated, but failed subscribers are not
|
// 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<LandblockRetirementTicket>? 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) =>
|
private static uint Canonicalize(uint id) =>
|
||||||
(id & 0xFFFF0000u) | 0xFFFFu;
|
(id & 0xFFFF0000u) | 0xFFFFu;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,13 @@ internal sealed class LocalPlayerTeleportController
|
||||||
+ $"new lb=({landblockX},{landblockY}) "
|
+ $"new lb=({landblockX},{landblockY}) "
|
||||||
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
|
+ $"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;
|
Vector3 worldPosition;
|
||||||
if (transition.ChangesStreamingCenter)
|
if (transition.ChangesStreamingCenter)
|
||||||
{
|
{
|
||||||
|
|
@ -707,9 +714,6 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_pendingCell = position.LandblockId;
|
_pendingCell = position.LandblockId;
|
||||||
_holdSeconds = 0f;
|
_holdSeconds = 0f;
|
||||||
_forced = false;
|
_forced = false;
|
||||||
_worldReveal.Begin(WorldRevealKind.Portal);
|
|
||||||
if (!IsCurrentLifetime(generation, sequence))
|
|
||||||
return false;
|
|
||||||
_streaming.SetPriority(
|
_streaming.SetPriority(
|
||||||
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
|
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
|
||||||
NearRingRadius);
|
NearRingRadius);
|
||||||
|
|
@ -746,7 +750,7 @@ internal sealed class LocalPlayerTeleportController
|
||||||
_holdSeconds = 0f;
|
_holdSeconds = 0f;
|
||||||
_forced = false;
|
_forced = false;
|
||||||
|
|
||||||
bool originConverged = _streaming.ResetRecenter(clearSession);
|
_streaming.ResetRecenter(clearSession);
|
||||||
if (_lifetimeGeneration != generation)
|
if (_lifetimeGeneration != generation)
|
||||||
return generation;
|
return generation;
|
||||||
|
|
||||||
|
|
@ -763,12 +767,6 @@ internal sealed class LocalPlayerTeleportController
|
||||||
|
|
||||||
_streaming.ClearPriority();
|
_streaming.ClearPriority();
|
||||||
|
|
||||||
if (clearSession && !originConverged)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
"The ending session's streaming-origin retirement has not converged.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return generation;
|
return generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,21 @@ internal sealed class StreamingCompletionQueue
|
||||||
_retainedCpuBytes = 0;
|
_retainedCpuBytes = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryRemoveOne()
|
||||||
|
{
|
||||||
|
for (int priority = 0; priority < _queues.Length; priority++)
|
||||||
|
{
|
||||||
|
Queue<StreamingQueuedCompletion> queue = _queues[priority];
|
||||||
|
if (!queue.TryDequeue(out StreamingQueuedCompletion completion))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Release(completion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public StreamingCompletionQueueSnapshot CaptureSnapshot()
|
public StreamingCompletionQueueSnapshot CaptureSnapshot()
|
||||||
{
|
{
|
||||||
long now = Stopwatch.GetTimestamp();
|
long now = Stopwatch.GetTimestamp();
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,24 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
public List<uint>? ResidentIds;
|
public List<uint>? ResidentIds;
|
||||||
public int RetirementCursor;
|
public int RetirementCursor;
|
||||||
public bool PreparationCommitted;
|
public bool PreparationCommitted;
|
||||||
|
public IEnumerator<uint>? ResidentEnumerator;
|
||||||
public (int X, int Y, bool IsSealedDungeon)? Destination;
|
public (int X, int Y, bool IsSealedDungeon)? Destination;
|
||||||
public bool DestinationConfigured;
|
public bool DestinationConfigured;
|
||||||
public bool DestinationLoadEnqueued;
|
public bool DestinationLoadEnqueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FullWindowRetirement
|
||||||
|
{
|
||||||
|
public bool GenerationAdvanced;
|
||||||
|
public bool PendingLoadsCleared;
|
||||||
|
public bool CompletionQueueCleared;
|
||||||
|
public bool RegionCleared;
|
||||||
|
public List<uint>? ResidentIds;
|
||||||
|
public IEnumerator<uint>? ResidentEnumerator;
|
||||||
|
public int RetirementCursor;
|
||||||
|
public bool PreparationCommitted;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
|
||||||
private readonly Action<uint, ulong> _enqueueUnload;
|
private readonly Action<uint, ulong> _enqueueUnload;
|
||||||
private readonly ILandblockCompletionSource _completionSource;
|
private readonly ILandblockCompletionSource _completionSource;
|
||||||
|
|
@ -55,6 +68,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
private bool _advancingRadiiReconfiguration;
|
private bool _advancingRadiiReconfiguration;
|
||||||
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
|
||||||
private OriginRecenterRetirement? _originRecenterRetirement;
|
private OriginRecenterRetirement? _originRecenterRetirement;
|
||||||
|
private FullWindowRetirement? _fullWindowRetirement;
|
||||||
private bool _advancingOriginRecenter;
|
private bool _advancingOriginRecenter;
|
||||||
// Hard streaming boundaries advance this token. Worker completions carry
|
// Hard streaming boundaries advance this token. Worker completions carry
|
||||||
// the generation captured at enqueue time, so an old overlapping load or
|
// 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)
|
private void DemoteLandblock(uint id)
|
||||||
{
|
{
|
||||||
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
|
||||||
_presentation.BeginNearLayerRetirement(canonical);
|
_presentation.EnqueueNearLayerRetirement(canonical);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AdvanceGeneration()
|
private bool AdvanceGeneration()
|
||||||
|
|
@ -530,9 +544,37 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
_activeWorkMeter = meter;
|
_activeWorkMeter = meter;
|
||||||
try
|
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)
|
if (_originRecenterRetirement is not null)
|
||||||
{
|
{
|
||||||
_presentation.AdvanceRetirements();
|
bool retirementWasPending =
|
||||||
|
_presentation.PendingRetirementCount != 0;
|
||||||
|
TryAdvanceOriginRecenterPreparation(meter);
|
||||||
|
if (_presentation.UsesBudgetedRetirementSteps
|
||||||
|
|| retirementWasPending)
|
||||||
|
{
|
||||||
|
_presentation.AdvanceRetirements(meter);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,12 +586,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
|
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
|
// Complete an admitted publication before this frame can mutate its
|
||||||
// desired tier or spatial residence. A later recenter may then demote
|
// desired tier or spatial residence. A later recenter may then demote
|
||||||
// or retire the fully known owner set through the normal ledger.
|
// or retire the fully known owner set through the normal ledger.
|
||||||
|
bool retirementWasPendingAtFrameStart =
|
||||||
|
_presentation.PendingRetirementCount != 0;
|
||||||
|
_presentation.AdvanceRetirements(meter);
|
||||||
if (!ConvergePendingPublications())
|
if (!ConvergePendingPublications())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
@ -584,6 +626,12 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
DrainAndApply();
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -882,10 +930,13 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a reload of the current streaming window. Logical residency is
|
/// Starts a reload of the current streaming window. The next and later
|
||||||
/// detached immediately; render, physics, and static-lighting owners then
|
/// <see cref="Tick"/> calls invalidate accepted work, capture and detach
|
||||||
/// converge through the retained presentation-retirement ledger before
|
/// stable residents, and converge render, physics, and static-lighting
|
||||||
/// <see cref="NormalTick"/> bootstraps the window again.
|
/// owners under the shared frame budget before <see cref="NormalTick"/>
|
||||||
|
/// bootstraps the window again. Hard login/portal transitions separately
|
||||||
|
/// publish their immediate observable edge through
|
||||||
|
/// <see cref="WorldGenerationQuiescence"/>.
|
||||||
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
|
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
|
||||||
/// every old-window owner retires while the old coordinate frame is active.
|
/// every old-window owner retires while the old coordinate frame is active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -916,10 +967,8 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"No streaming-origin recenter transaction is pending.");
|
"No streaming-origin recenter transaction is pending.");
|
||||||
|
|
||||||
if (!TryAdvanceOriginRecenterPreparation())
|
return _originRecenterRetirement.PreparationCommitted
|
||||||
return false;
|
&& _presentation.PendingRetirementCount == 0;
|
||||||
_presentation.AdvanceRetirements();
|
|
||||||
return _presentation.PendingRetirementCount == 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1040,8 +1089,9 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryAdvanceOriginRecenterPreparation()
|
private bool TryAdvanceOriginRecenterPreparation(StreamingWorkMeter meter)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(meter);
|
||||||
OriginRecenterRetirement transaction = _originRecenterRetirement
|
OriginRecenterRetirement transaction = _originRecenterRetirement
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"No streaming-origin recenter transaction is pending.");
|
"No streaming-origin recenter transaction is pending.");
|
||||||
|
|
@ -1055,58 +1105,159 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
{
|
{
|
||||||
if (!transaction.RadiiConverged)
|
if (!transaction.RadiiConverged)
|
||||||
{
|
{
|
||||||
if (_pendingRadiiReconfiguration is not null)
|
if (_pendingRadiiReconfiguration is not null
|
||||||
AdvanceRadiiReconfiguration();
|
&& !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)
|
if (_pendingRadiiReconfiguration is not null)
|
||||||
return false;
|
return false;
|
||||||
transaction.RadiiConverged = true;
|
transaction.RadiiConverged = true;
|
||||||
}
|
}
|
||||||
if (!transaction.GenerationAdvanced)
|
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())
|
if (!AdvanceGeneration())
|
||||||
return false;
|
return false;
|
||||||
transaction.GenerationAdvanced = true;
|
transaction.GenerationAdvanced = true;
|
||||||
}
|
}
|
||||||
if (!transaction.PendingLoadsCleared)
|
if (!transaction.PendingLoadsCleared)
|
||||||
{
|
{
|
||||||
try
|
bool ClearPendingLoads()
|
||||||
{
|
{
|
||||||
_clearPendingLoads?.Invoke();
|
try
|
||||||
transaction.PendingLoadsCleared = true;
|
{
|
||||||
|
_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)
|
|
||||||
{
|
if (!TryRunStreamingWork(
|
||||||
transaction.PendingLoadsCleared = true;
|
meter,
|
||||||
Console.WriteLine(
|
new StreamingWorkCost(EntityOperations: 1),
|
||||||
$"streaming: committed pending-load clear reported failure: {error}");
|
"recenter-clear-worker-inbox",
|
||||||
|
ClearPendingLoads))
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!transaction.CompletionQueueCleared)
|
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;
|
transaction.CompletionQueueCleared = true;
|
||||||
}
|
}
|
||||||
if (!transaction.RegionCleared)
|
if (!transaction.RegionCleared)
|
||||||
{
|
{
|
||||||
_collapsed = false;
|
if (!TryRunStreamingWork(
|
||||||
_region = null;
|
meter,
|
||||||
|
new StreamingWorkCost(EntityOperations: 1),
|
||||||
|
"recenter-clear-region",
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
_collapsed = false;
|
||||||
|
_region = null;
|
||||||
|
return true;
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
transaction.RegionCleared = true;
|
transaction.RegionCleared = true;
|
||||||
}
|
}
|
||||||
if (transaction.ResidentIds is null)
|
if (transaction.ResidentIds is null)
|
||||||
{
|
{
|
||||||
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
|
transaction.ResidentIds = [];
|
||||||
FullWindowRetirementCount++;
|
transaction.ResidentEnumerator =
|
||||||
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
|
_state.LoadedLandblockIds.GetEnumerator();
|
||||||
}
|
}
|
||||||
|
while (transaction.ResidentEnumerator is { } residentEnumerator)
|
||||||
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
|
|
||||||
{
|
{
|
||||||
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
|
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<uint> 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++;
|
transaction.RetirementCursor++;
|
||||||
|
meter.Complete();
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -1116,6 +1267,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
// otherwise retain it for the next frame.
|
// otherwise retain it for the next frame.
|
||||||
if (!_state.IsLoaded(id))
|
if (!_state.IsLoaded(id))
|
||||||
transaction.RetirementCursor++;
|
transaction.RetirementCursor++;
|
||||||
|
meter.Fail();
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$"streaming: origin-recenter retirement for 0x{id:X8} " +
|
$"streaming: origin-recenter retirement for 0x{id:X8} " +
|
||||||
$"will resume: {error}");
|
$"will resume: {error}");
|
||||||
|
|
@ -1140,21 +1292,178 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
|
|
||||||
private void BeginFullWindowRetirement()
|
private void BeginFullWindowRetirement()
|
||||||
{
|
{
|
||||||
if (!AdvanceGeneration())
|
_fullWindowRetirement ??= new FullWindowRetirement();
|
||||||
return;
|
}
|
||||||
_collapsed = false;
|
|
||||||
_clearPendingLoads?.Invoke();
|
private bool TryAdvanceFullWindowRetirement(StreamingWorkMeter meter)
|
||||||
_completionQueue.Clear();
|
{
|
||||||
// Commit the old region boundary before any presentation owner is
|
FullWindowRetirement transaction = _fullWindowRetirement
|
||||||
// advanced. New same-id publications are fenced by the presentation
|
?? throw new InvalidOperationException(
|
||||||
// pipeline's retryable retirement ledger.
|
"No full-window retirement transaction is pending.");
|
||||||
_region = null;
|
if (transaction.PreparationCommitted)
|
||||||
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
|
return true;
|
||||||
var ids = new List<uint>(_state.LoadedLandblockIds);
|
|
||||||
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
|
try
|
||||||
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
|
{
|
||||||
foreach (var id in ids)
|
if (!transaction.GenerationAdvanced)
|
||||||
_presentation.BeginFullRetirement(id);
|
{
|
||||||
|
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<uint> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1209,6 +1518,32 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryRunStreamingWork(
|
||||||
|
StreamingWorkMeter meter,
|
||||||
|
StreamingWorkCost cost,
|
||||||
|
string stage,
|
||||||
|
Func<bool> 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)
|
private void AdmitCompletions(StreamingWorkMeter meter)
|
||||||
{
|
{
|
||||||
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
|
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
|
||||||
|
|
@ -1439,7 +1774,7 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Unloaded unloaded:
|
case LandblockStreamResult.Unloaded unloaded:
|
||||||
_presentation.BeginFullRetirement(unloaded.LandblockId);
|
_presentation.EnqueueFullRetirement(unloaded.LandblockId);
|
||||||
break;
|
break;
|
||||||
case LandblockStreamResult.Failed failed:
|
case LandblockStreamResult.Failed failed:
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
@ -1458,14 +1793,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|
||||||
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
|
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
|
||||||
{
|
{
|
||||||
if (_state.IsLoaded(landblockId))
|
if (_state.IsLoaded(landblockId))
|
||||||
_presentation.BeginFullRetirement(landblockId);
|
_presentation.EnqueueFullRetirement(landblockId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desiredTier == LandblockStreamTier.Far
|
if (desiredTier == LandblockStreamTier.Far
|
||||||
&& _state.IsNearTier(landblockId))
|
&& _state.IsNearTier(landblockId))
|
||||||
{
|
{
|
||||||
_presentation.BeginNearLayerRetirement(landblockId);
|
_presentation.EnqueueNearLayerRetirement(landblockId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
|
||||||
public bool IsPending => _pending is not null;
|
public bool IsPending => _pending is not null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Begins a new recenter transaction and performs its first retirement
|
/// Begins a new recenter transaction. Its retained preparation and
|
||||||
/// attempt immediately. A teleport replacement must call <see cref="Reset"/>
|
/// retirement cursors advance only from <see cref="StreamingController.Tick"/>
|
||||||
/// before supplying a different destination.
|
/// under that frame's shared work meter. A teleport replacement must call
|
||||||
|
/// <see cref="Reset"/> before supplying a different destination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
|
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
|
||||||
{
|
{
|
||||||
|
|
@ -71,8 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Advances retained presentation teardown. Returns true only after the
|
/// Observes retained presentation teardown and commits the new origin once
|
||||||
/// new origin is committed and destination streaming has been unblocked.
|
/// the streaming frame owner has converged it. Returns true only after the
|
||||||
|
/// destination streaming gate has been released.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Advance()
|
public bool Advance()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
109
src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
Normal file
109
src/AcDream.App/Streaming/WorldGenerationQuiescence.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using AcDream.App.Audio;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
|
namespace AcDream.App.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single-writer state behind <see cref="IWorldGenerationAvailability"/>.
|
||||||
|
/// Superseding reveal generations stay continuously quiesced; only the exact
|
||||||
|
/// active generation may reopen the world.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the immediate observable edge of retail
|
||||||
|
/// <c>SmartBox::UseTime @ 0x00455410</c>'s <c>blocking_for_cells</c>
|
||||||
|
/// branch. Deferred teardown may retain memory, but the old generation cannot
|
||||||
|
/// keep rendering, targeting, ticking, or producing world audio.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ internal sealed class WorldRevealCoordinator
|
||||||
{
|
{
|
||||||
private readonly WorldRevealReadinessBarrier _readiness;
|
private readonly WorldRevealReadinessBarrier _readiness;
|
||||||
private readonly WorldRevealLifecycleTelemetry _lifecycle;
|
private readonly WorldRevealLifecycleTelemetry _lifecycle;
|
||||||
|
private readonly WorldGenerationQuiescence? _quiescence;
|
||||||
|
private long _activeGeneration;
|
||||||
|
|
||||||
public WorldRevealCoordinator(
|
public WorldRevealCoordinator(
|
||||||
Func<uint, int, bool> isRenderNeighborhoodReady,
|
Func<uint, int, bool> isRenderNeighborhoodReady,
|
||||||
|
|
@ -19,7 +21,8 @@ internal sealed class WorldRevealCoordinator
|
||||||
Action<uint, int> prepareCompositeTextures,
|
Action<uint, int> prepareCompositeTextures,
|
||||||
Action invalidateCompositeTextures,
|
Action invalidateCompositeTextures,
|
||||||
Func<uint, bool> isSpawnClaimUnhydratable,
|
Func<uint, bool> isSpawnClaimUnhydratable,
|
||||||
Action<string>? log = null)
|
Action<string>? log = null,
|
||||||
|
WorldGenerationQuiescence? quiescence = null)
|
||||||
{
|
{
|
||||||
_readiness = new WorldRevealReadinessBarrier(
|
_readiness = new WorldRevealReadinessBarrier(
|
||||||
isRenderNeighborhoodReady,
|
isRenderNeighborhoodReady,
|
||||||
|
|
@ -30,6 +33,7 @@ internal sealed class WorldRevealCoordinator
|
||||||
invalidateCompositeTextures,
|
invalidateCompositeTextures,
|
||||||
isSpawnClaimUnhydratable);
|
isSpawnClaimUnhydratable);
|
||||||
_lifecycle = new WorldRevealLifecycleTelemetry(log);
|
_lifecycle = new WorldRevealLifecycleTelemetry(log);
|
||||||
|
_quiescence = quiescence;
|
||||||
}
|
}
|
||||||
|
|
||||||
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
|
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
|
||||||
|
|
@ -38,7 +42,10 @@ internal sealed class WorldRevealCoordinator
|
||||||
public long Begin(WorldRevealKind kind)
|
public long Begin(WorldRevealKind kind)
|
||||||
{
|
{
|
||||||
_readiness.Begin();
|
_readiness.Begin();
|
||||||
return _lifecycle.Begin(kind);
|
long generation = _lifecycle.Begin(kind);
|
||||||
|
_activeGeneration = generation;
|
||||||
|
_quiescence?.Begin(generation);
|
||||||
|
return generation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -63,7 +70,25 @@ internal sealed class WorldRevealCoordinator
|
||||||
public void ObserveWorldViewportVisible() =>
|
public void ObserveWorldViewportVisible() =>
|
||||||
_lifecycle.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
namespace AcDream.App.Update;
|
namespace AcDream.App.Update;
|
||||||
|
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The host-supplied input for one update callback.
|
/// The host-supplied input for one update callback.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -28,10 +30,13 @@ internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource
|
||||||
{
|
{
|
||||||
public double CurrentScriptTime { get; private set; }
|
public double CurrentScriptTime { get; private set; }
|
||||||
|
|
||||||
public UpdateFrameTiming Advance(UpdateFrameInput input)
|
public UpdateFrameTiming Advance(
|
||||||
|
UpdateFrameInput input,
|
||||||
|
bool advanceScriptClock = true)
|
||||||
{
|
{
|
||||||
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
|
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
|
||||||
CurrentScriptTime += deltaSeconds;
|
if (advanceScriptClock)
|
||||||
|
CurrentScriptTime += deltaSeconds;
|
||||||
return new UpdateFrameTiming(
|
return new UpdateFrameTiming(
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
(float)deltaSeconds,
|
(float)deltaSeconds,
|
||||||
|
|
@ -122,6 +127,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
|
||||||
private readonly ILocalPlayerTeleportFramePhase _teleport;
|
private readonly ILocalPlayerTeleportFramePhase _teleport;
|
||||||
private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry;
|
private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry;
|
||||||
private readonly ICameraFramePhase _camera;
|
private readonly ICameraFramePhase _camera;
|
||||||
|
private readonly IWorldGenerationAvailability _availability;
|
||||||
|
|
||||||
public UpdateFrameOrchestrator(
|
public UpdateFrameOrchestrator(
|
||||||
IUpdateFrameTeardownPhase teardown,
|
IUpdateFrameTeardownPhase teardown,
|
||||||
|
|
@ -134,7 +140,8 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
|
||||||
ILiveEntityLivenessFramePhase liveness,
|
ILiveEntityLivenessFramePhase liveness,
|
||||||
ILocalPlayerTeleportFramePhase teleport,
|
ILocalPlayerTeleportFramePhase teleport,
|
||||||
IPlayerModeAutoEntryFramePhase playerModeAutoEntry,
|
IPlayerModeAutoEntryFramePhase playerModeAutoEntry,
|
||||||
ICameraFramePhase camera)
|
ICameraFramePhase camera,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
|
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
|
||||||
_failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink));
|
_failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink));
|
||||||
|
|
@ -149,6 +156,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
|
||||||
_playerModeAutoEntry = playerModeAutoEntry
|
_playerModeAutoEntry = playerModeAutoEntry
|
||||||
?? throw new ArgumentNullException(nameof(playerModeAutoEntry));
|
?? throw new ArgumentNullException(nameof(playerModeAutoEntry));
|
||||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||||
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Tick(UpdateFrameInput input)
|
public void Tick(UpdateFrameInput input)
|
||||||
|
|
@ -165,12 +173,15 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
|
||||||
_failureSink.ReportTeardownFailure(error);
|
_failureSink.ReportTeardownFailure(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateFrameTiming timing = _clock.Advance(input);
|
UpdateFrameTiming timing = _clock.Advance(
|
||||||
|
input,
|
||||||
|
advanceScriptClock: _availability.IsWorldAvailable);
|
||||||
_scriptClockPublisher.PublishTime(timing.ScriptTime);
|
_scriptClockPublisher.PublishTime(timing.ScriptTime);
|
||||||
_streaming.Tick();
|
_streaming.Tick();
|
||||||
_input.Tick(timing);
|
_input.Tick(timing);
|
||||||
_liveFrame.Tick(timing.SimulationDeltaSecondsSingle);
|
_liveFrame.Tick(timing.SimulationDeltaSecondsSingle);
|
||||||
_liveness.Tick();
|
if (_availability.IsWorldAvailable)
|
||||||
|
_liveness.Tick();
|
||||||
_teleport.Tick(timing.SimulationDeltaSecondsSingle);
|
_teleport.Tick(timing.SimulationDeltaSecondsSingle);
|
||||||
_playerModeAutoEntry.TryEnter();
|
_playerModeAutoEntry.TryEnter();
|
||||||
_camera.Tick(timing);
|
_camera.Tick(timing);
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,15 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
|
||||||
private readonly ILiveSessionFramePhase _session;
|
private readonly ILiveSessionFramePhase _session;
|
||||||
private readonly IPostNetworkCommandFramePhase _localPlayer;
|
private readonly IPostNetworkCommandFramePhase _localPlayer;
|
||||||
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
|
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
|
||||||
|
private readonly IWorldGenerationAvailability _availability;
|
||||||
|
|
||||||
public RetailLiveFrameCoordinator(
|
public RetailLiveFrameCoordinator(
|
||||||
ILiveObjectFramePhase objects,
|
ILiveObjectFramePhase objects,
|
||||||
GpuWorldState worldState,
|
GpuWorldState worldState,
|
||||||
ILiveSessionFramePhase session,
|
ILiveSessionFramePhase session,
|
||||||
IPostNetworkCommandFramePhase localPlayer,
|
IPostNetworkCommandFramePhase localPlayer,
|
||||||
ILiveSpatialReconcilePhase spatialReconciler)
|
ILiveSpatialReconcilePhase spatialReconciler,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||||
|
|
@ -38,16 +40,19 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
|
||||||
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
|
||||||
_spatialReconciler = spatialReconciler
|
_spatialReconciler = spatialReconciler
|
||||||
?? throw new ArgumentNullException(nameof(spatialReconciler));
|
?? throw new ArgumentNullException(nameof(spatialReconciler));
|
||||||
|
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Tick(float deltaSeconds)
|
public void Tick(float deltaSeconds)
|
||||||
{
|
{
|
||||||
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
|
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
|
||||||
_objects.Tick(frameDelta);
|
if (_availability.IsWorldAvailable)
|
||||||
|
_objects.Tick(frameDelta);
|
||||||
using (_worldState.BeginMutationBatch())
|
using (_worldState.BeginMutationBatch())
|
||||||
_session.Tick();
|
_session.Tick();
|
||||||
_localPlayer.RunPostNetworkCommandPhase();
|
_localPlayer.RunPostNetworkCommandPhase();
|
||||||
_spatialReconciler.Reconcile();
|
if (_availability.IsWorldAvailable)
|
||||||
|
_spatialReconciler.Reconcile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
|
public static double NormalizeDeltaSeconds(double deltaSeconds) =>
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,13 @@ public sealed class LiveSessionResetPlanTests
|
||||||
Assert.Equal(1, identityResets);
|
Assert.Equal(1, identityResets);
|
||||||
|
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
|
for (int frame = 0;
|
||||||
|
frame < 64
|
||||||
|
&& !controller.IsOriginRecenterRetirementComplete();
|
||||||
|
frame++)
|
||||||
|
{
|
||||||
|
controller.Tick(0x53, 0x53);
|
||||||
|
}
|
||||||
plan.Execute();
|
plan.Execute();
|
||||||
|
|
||||||
Assert.Equal(2, identityResets);
|
Assert.Equal(2, identityResets);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Reflection;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.Rendering.Selection;
|
using AcDream.App.Rendering.Selection;
|
||||||
using AcDream.App.Rendering.Vfx;
|
using AcDream.App.Rendering.Vfx;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
using AcDream.Core.World;
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
|
@ -21,6 +22,23 @@ public sealed class WorldSceneRendererTests
|
||||||
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
|
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]
|
[Fact]
|
||||||
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
|
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
|
||||||
{
|
{
|
||||||
|
|
@ -332,7 +350,8 @@ public sealed class WorldSceneRendererTests
|
||||||
bool waitingForLogin,
|
bool waitingForLogin,
|
||||||
LoadedCell? clipRoot,
|
LoadedCell? clipRoot,
|
||||||
bool? playerSeenOutside = null,
|
bool? playerSeenOutside = null,
|
||||||
EnvironOverride environOverride = EnvironOverride.None)
|
EnvironOverride environOverride = EnvironOverride.None,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
Calls = [];
|
Calls = [];
|
||||||
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
|
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
|
||||||
|
|
@ -385,7 +404,8 @@ public sealed class WorldSceneRendererTests
|
||||||
new PViewCells(),
|
new PViewCells(),
|
||||||
Passes,
|
Passes,
|
||||||
new WorldRenderRangeState(4, 12),
|
new WorldRenderRangeState(4, 12),
|
||||||
diagnostics);
|
diagnostics,
|
||||||
|
availability);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<string> Calls { get; }
|
public List<string> Calls { get; }
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||||
|
|
||||||
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
|
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
|
||||||
|
controller.Tick(0x19, 0x19);
|
||||||
|
|
||||||
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
|
||||||
Assert.False(state.IsLoaded(centerId));
|
Assert.False(state.IsLoaded(centerId));
|
||||||
|
|
@ -144,12 +145,11 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.Equal(1, controller.PendingRetirementCount);
|
Assert.Equal(1, controller.PendingRetirementCount);
|
||||||
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
|
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
|
||||||
|
|
||||||
controller.Tick(0x19, 0x19);
|
|
||||||
Assert.Empty(enqueued);
|
Assert.Empty(enqueued);
|
||||||
Assert.True(neighborAttempts >= 2);
|
Assert.Equal(1, neighborAttempts);
|
||||||
|
|
||||||
failNeighbor = false;
|
failNeighbor = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x19, 0x19));
|
||||||
|
|
||||||
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
|
||||||
Assert.Equal(0, controller.PendingRetirementCount);
|
Assert.Equal(0, controller.PendingRetirementCount);
|
||||||
|
|
@ -187,12 +187,13 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||||
|
|
||||||
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
|
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
|
||||||
|
controller.Tick(0x30, 0x30);
|
||||||
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
|
||||||
Assert.True(state.IsLoaded(landblockId));
|
Assert.True(state.IsLoaded(landblockId));
|
||||||
Assert.Equal(0, controller.PendingRetirementCount);
|
Assert.Equal(0, controller.PendingRetirementCount);
|
||||||
|
|
||||||
failClear = false;
|
failClear = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x30, 0x30));
|
||||||
|
|
||||||
Assert.Equal(2, clearAttempts);
|
Assert.Equal(2, clearAttempts);
|
||||||
Assert.False(state.IsLoaded(landblockId));
|
Assert.False(state.IsLoaded(landblockId));
|
||||||
|
|
@ -234,13 +235,15 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||||
|
|
||||||
Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false));
|
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.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY));
|
||||||
Assert.False(state.IsLoaded(firstId));
|
Assert.False(state.IsLoaded(firstId));
|
||||||
Assert.False(state.IsLoaded(secondId));
|
Assert.False(state.IsLoaded(secondId));
|
||||||
Assert.Equal(1, observerCalls);
|
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((0x43, 0x44), (origin.CenterX, origin.CenterY));
|
||||||
Assert.Equal([firstId, secondId], removed);
|
Assert.Equal([firstId, secondId], removed);
|
||||||
}
|
}
|
||||||
|
|
@ -281,13 +284,14 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||||
|
|
||||||
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
|
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
|
||||||
|
controller.Tick(0x45, 0x45);
|
||||||
|
|
||||||
Assert.Same(player, Assert.Single(state.DrainRescued()));
|
Assert.Same(player, Assert.Single(state.DrainRescued()));
|
||||||
Assert.Equal(1, state.PendingLiveEntityCount);
|
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||||
Assert.Empty(state.Entities);
|
Assert.Empty(state.Entities);
|
||||||
|
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x45, 0x45));
|
||||||
Assert.Empty(state.DrainRescued());
|
Assert.Empty(state.DrainRescued());
|
||||||
state.AddLandblock(new LoadedLandblock(
|
state.AddLandblock(new LoadedLandblock(
|
||||||
destinationId,
|
destinationId,
|
||||||
|
|
@ -344,7 +348,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.True(recenter.IsPending);
|
Assert.True(recenter.IsPending);
|
||||||
|
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x50, 0x50));
|
||||||
|
|
||||||
Assert.False(recenter.IsPending);
|
Assert.False(recenter.IsPending);
|
||||||
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
|
||||||
|
|
@ -384,7 +388,12 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
|
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
|
||||||
recenter.Reset();
|
recenter.Reset();
|
||||||
failRetirement = false;
|
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((0x52, 0x52), (origin.CenterX, origin.CenterY));
|
||||||
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
|
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
|
||||||
|
|
@ -423,7 +432,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
|
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
|
||||||
recenter.Reset(sessionEnding: true);
|
recenter.Reset(sessionEnding: true);
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x53, 0x53));
|
||||||
|
|
||||||
Assert.Empty(enqueued);
|
Assert.Empty(enqueued);
|
||||||
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
|
||||||
|
|
@ -464,7 +473,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.True(origin.TryInitialize(0x61, 0x62));
|
Assert.True(origin.TryInitialize(0x61, 0x62));
|
||||||
|
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x61, 0x62));
|
||||||
|
|
||||||
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +503,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
||||||
|
|
||||||
Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false));
|
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(1, clearAttempts);
|
||||||
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
|
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
|
||||||
|
|
@ -537,10 +546,11 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
removed.Add(id);
|
removed.Add(id);
|
||||||
if (id == firstId)
|
if (id == firstId)
|
||||||
Assert.False(controller!.IsOriginRecenterRetirementComplete());
|
Assert.False(controller!.IsOriginRecenterRetirementComplete());
|
||||||
});
|
});
|
||||||
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
|
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(1, clearCalls);
|
||||||
Assert.Equal([firstId, secondId], removed);
|
Assert.Equal([firstId, secondId], removed);
|
||||||
|
|
@ -585,7 +595,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.Empty(enqueued);
|
Assert.Empty(enqueued);
|
||||||
|
|
||||||
failRetirement = false;
|
failRetirement = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(Converge(recenter, controller, 0x72, 0x72));
|
||||||
controller.Tick(0x73, 0x73);
|
controller.Tick(0x73, 0x73);
|
||||||
|
|
||||||
Assert.Equal(1, controller.NearRadius);
|
Assert.Equal(1, controller.NearRadius);
|
||||||
|
|
@ -630,6 +640,7 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.True(recenter.IsPending);
|
Assert.True(recenter.IsPending);
|
||||||
controller.Tick(0x30, 0x30, insideDungeon: true);
|
controller.Tick(0x30, 0x30, insideDungeon: true);
|
||||||
Assert.Empty(enqueued);
|
Assert.Empty(enqueued);
|
||||||
|
Assert.False(recenter.Advance());
|
||||||
|
|
||||||
failDungeonEnqueue = false;
|
failDungeonEnqueue = false;
|
||||||
Assert.True(recenter.Advance());
|
Assert.True(recenter.Advance());
|
||||||
|
|
@ -1066,6 +1077,8 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.True(state.IsNearTier(landblockId));
|
Assert.True(state.IsNearTier(landblockId));
|
||||||
|
|
||||||
controller.ForceReloadWindow();
|
controller.ForceReloadWindow();
|
||||||
|
for (int frame = 0; frame < 16 && state.IsLoaded(landblockId); frame++)
|
||||||
|
controller.Tick(0x80, 0x80);
|
||||||
|
|
||||||
Assert.False(state.IsLoaded(landblockId));
|
Assert.False(state.IsLoaded(landblockId));
|
||||||
Assert.Equal(1, presentationCalls);
|
Assert.Equal(1, presentationCalls);
|
||||||
|
|
@ -1276,6 +1289,25 @@ public sealed class LandblockPresentationPipelineTests
|
||||||
Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase);
|
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(
|
private static LandblockBuild BuildWithShell(
|
||||||
uint landblockId,
|
uint landblockId,
|
||||||
params WorldEntity[] entities)
|
params WorldEntity[] entities)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,101 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
Assert.Equal(1, calls[3]);
|
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<uint, int>();
|
||||||
|
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<uint, int>();
|
||||||
|
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]
|
[Fact]
|
||||||
public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending()
|
public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending()
|
||||||
{
|
{
|
||||||
|
|
@ -59,31 +154,88 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
foreach (uint id in ids)
|
foreach (uint id in ids)
|
||||||
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||||
|
|
||||||
bool failOnce = true;
|
bool failRetirement = true;
|
||||||
var coordinator = new LandblockRetirementCoordinator(
|
var coordinator = new LandblockRetirementCoordinator(
|
||||||
state,
|
state,
|
||||||
ticket => CompletePresentation(
|
ticket => CompletePresentation(
|
||||||
ticket,
|
ticket,
|
||||||
terrain: () =>
|
terrain: () =>
|
||||||
{
|
{
|
||||||
if (ticket.LandblockId == ids[1] && failOnce)
|
if (ticket.LandblockId == ids[1] && failRetirement)
|
||||||
{
|
{
|
||||||
failOnce = false;
|
|
||||||
throw new InvalidOperationException("injected terrain failure");
|
throw new InvalidOperationException("injected terrain failure");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
var controller = Controller(state, coordinator);
|
var controller = Controller(state, coordinator);
|
||||||
|
|
||||||
controller.ForceReloadWindow();
|
controller.ForceReloadWindow();
|
||||||
|
for (int frame = 0;
|
||||||
|
frame < 16 && ids.Any(state.IsLoaded);
|
||||||
|
frame++)
|
||||||
|
{
|
||||||
|
controller.Tick(0x20, 0x20);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (uint id in ids)
|
foreach (uint id in ids)
|
||||||
Assert.False(state.IsLoaded(id));
|
Assert.False(state.IsLoaded(id));
|
||||||
Assert.Equal(1, coordinator.PendingCount);
|
Assert.Equal(1, coordinator.PendingCount);
|
||||||
|
|
||||||
|
failRetirement = false;
|
||||||
coordinator.Advance();
|
coordinator.Advance();
|
||||||
Assert.Equal(0, coordinator.PendingCount);
|
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<WorldEntity>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
[Fact]
|
||||||
public void ReplacementPublication_WaitsForSameIdRetirementFence()
|
public void ReplacementPublication_WaitsForSameIdRetirementFence()
|
||||||
{
|
{
|
||||||
|
|
@ -126,12 +278,18 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
EmptyMesh(),
|
EmptyMesh(),
|
||||||
generation: 1));
|
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.Equal(0, applyCount);
|
||||||
Assert.False(state.IsLoaded(landblockId));
|
Assert.False(state.IsLoaded(landblockId));
|
||||||
Assert.True(coordinator.IsPending(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.Equal(1, applyCount);
|
||||||
Assert.True(state.IsLoaded(landblockId));
|
Assert.True(state.IsLoaded(landblockId));
|
||||||
Assert.False(coordinator.IsPending(landblockId));
|
Assert.False(coordinator.IsPending(landblockId));
|
||||||
|
|
@ -338,7 +496,8 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
GpuWorldState state,
|
GpuWorldState state,
|
||||||
LandblockRetirementCoordinator coordinator,
|
LandblockRetirementCoordinator coordinator,
|
||||||
Func<int, IReadOnlyList<LandblockStreamResult>>? drain = null,
|
Func<int, IReadOnlyList<LandblockStreamResult>>? drain = null,
|
||||||
Action<LandblockBuild, LandblockMeshData>? apply = null) =>
|
Action<LandblockBuild, LandblockMeshData>? apply = null,
|
||||||
|
StreamingWorkBudgetOptions? workBudgetOptions = null) =>
|
||||||
new(
|
new(
|
||||||
enqueueLoad: (_, _, _) => { },
|
enqueueLoad: (_, _, _) => { },
|
||||||
enqueueUnload: (_, _) => { },
|
enqueueUnload: (_, _) => { },
|
||||||
|
|
@ -347,7 +506,8 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
state: state,
|
state: state,
|
||||||
nearRadius: 0,
|
nearRadius: 0,
|
||||||
farRadius: 0,
|
farRadius: 0,
|
||||||
retirementCoordinator: coordinator);
|
retirementCoordinator: coordinator,
|
||||||
|
workBudgetOptions: workBudgetOptions);
|
||||||
|
|
||||||
private static IReadOnlyList<LandblockStreamResult> Drain(
|
private static IReadOnlyList<LandblockStreamResult> Drain(
|
||||||
Queue<LandblockStreamResult> pending,
|
Queue<LandblockStreamResult> pending,
|
||||||
|
|
@ -359,6 +519,60 @@ public sealed class LandblockRetirementCoordinatorTests
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static LandblockRetirementOperationResult AdvancePresentationStep(
|
||||||
|
LandblockRetirementTicket ticket,
|
||||||
|
Action<WorldEntity>? 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(
|
private static void CompletePresentation(
|
||||||
LandblockRetirementTicket ticket,
|
LandblockRetirementTicket ticket,
|
||||||
Action<WorldEntity>? lighting = null,
|
Action<WorldEntity>? lighting = null,
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence()
|
public void SessionReset_ClearsTransitAndLetsRecenterConvergeAcrossLaterFrames()
|
||||||
{
|
{
|
||||||
var harness = new Harness();
|
var harness = new Harness();
|
||||||
harness.Controller.OnTeleportStarted(12);
|
harness.Controller.OnTeleportStarted(12);
|
||||||
|
|
@ -291,7 +291,9 @@ public sealed class LocalPlayerTeleportControllerTests
|
||||||
|
|
||||||
var failed = new Harness();
|
var failed = new Harness();
|
||||||
failed.Streaming.ResetResult = false;
|
failed.Streaming.ResetResult = false;
|
||||||
Assert.Throws<InvalidOperationException>(() => failed.Controller.ResetSession());
|
failed.Controller.ResetSession();
|
||||||
|
Assert.False(failed.Controller.IsActive);
|
||||||
|
Assert.True(failed.Streaming.LastResetWasSessionEnding);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -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<WorldEntity>()));
|
||||||
|
world.PlaceLiveEntityProjection(LandblockId, entity);
|
||||||
|
world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One);
|
||||||
|
var nearby = new List<KeyValuePair<uint, WorldEntity>>();
|
||||||
|
|
||||||
|
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<WorldEntity>()));
|
||||||
|
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<MeshRef>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Core.Selection;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Streaming;
|
namespace AcDream.App.Tests.Streaming;
|
||||||
|
|
||||||
|
|
@ -114,4 +115,46 @@ public sealed class WorldRevealCoordinatorTests
|
||||||
Assert.Equal(default, coordinator.Snapshot.Readiness);
|
Assert.Equal(default, coordinator.Snapshot.Readiness);
|
||||||
Assert.Equal(0, coordinator.PortalMaterializationCount);
|
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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,25 @@ public sealed class RecallTeleportAnimationTests
|
||||||
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
|
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LiveFrame_WhileCellsAreBlocked_DrainsNetworkAndCommandsWithoutWorldTicks()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
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]
|
[Fact]
|
||||||
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
|
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
using AcDream.App.Update;
|
using AcDream.App.Update;
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
|
|
||||||
|
|
@ -129,6 +130,33 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
observed.Camera.Select(value => value.SimulationDeltaSeconds));
|
observed.Camera.Select(value => value.SimulationDeltaSeconds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void QuiescedWorld_FreezesScriptClockAndLivenessWhilePortalPhasesContinue()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
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]
|
[Fact]
|
||||||
public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange()
|
public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange()
|
||||||
{
|
{
|
||||||
|
|
@ -200,6 +228,7 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
typeof(ILocalPlayerTeleportFramePhase),
|
typeof(ILocalPlayerTeleportFramePhase),
|
||||||
typeof(IPlayerModeAutoEntryFramePhase),
|
typeof(IPlayerModeAutoEntryFramePhase),
|
||||||
typeof(ICameraFramePhase),
|
typeof(ICameraFramePhase),
|
||||||
|
typeof(IWorldGenerationAvailability),
|
||||||
];
|
];
|
||||||
|
|
||||||
FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields(
|
FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields(
|
||||||
|
|
@ -534,7 +563,8 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
"new LiveEntityLivenessFramePhase(",
|
"new LiveEntityLivenessFramePhase(",
|
||||||
"session.LocalTeleport,",
|
"session.LocalTeleport,",
|
||||||
"new PlayerModeAutoEntryFramePhase(",
|
"new PlayerModeAutoEntryFramePhase(",
|
||||||
"cameraFrame);");
|
"cameraFrame,",
|
||||||
|
"live.WorldAvailability);");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -860,7 +890,8 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
RecordingFailureSink? failureSink = null,
|
RecordingFailureSink? failureSink = null,
|
||||||
FrameObservations? observed = null,
|
FrameObservations? observed = null,
|
||||||
bool teleportPlace = false,
|
bool teleportPlace = false,
|
||||||
bool inboundCreatedPlayer = false)
|
bool inboundCreatedPlayer = false,
|
||||||
|
IWorldGenerationAvailability? availability = null)
|
||||||
{
|
{
|
||||||
teardown ??= new RecordingTeardown(calls);
|
teardown ??= new RecordingTeardown(calls);
|
||||||
failureSink ??= new RecordingFailureSink();
|
failureSink ??= new RecordingFailureSink();
|
||||||
|
|
@ -875,7 +906,8 @@ public sealed class UpdateFrameOrchestratorTests
|
||||||
new RecordingLiveness(calls),
|
new RecordingLiveness(calls),
|
||||||
new RecordingTeleport(calls, observed, teleportPlace),
|
new RecordingTeleport(calls, observed, teleportPlace),
|
||||||
new RecordingAutoEntry(calls),
|
new RecordingAutoEntry(calls),
|
||||||
new RecordingCamera(calls, observed, inboundCreatedPlayer));
|
new RecordingCamera(calls, observed, inboundCreatedPlayer),
|
||||||
|
availability);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class RecordingTeardown(List<string> calls)
|
private sealed class RecordingTeardown(List<string> calls)
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,8 @@ public class StreamingControllerPriorityApplyTests
|
||||||
|
|
||||||
ctrl.ForceReloadWindow();
|
ctrl.ForceReloadWindow();
|
||||||
loads.Clear();
|
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;
|
ulong newGeneration = loads.Single(load => load.Id == overlap).Generation;
|
||||||
Assert.NotEqual(oldGeneration, newGeneration);
|
Assert.NotEqual(oldGeneration, newGeneration);
|
||||||
|
|
||||||
|
|
@ -551,7 +552,8 @@ public class StreamingControllerPriorityApplyTests
|
||||||
outbox.Enqueue(LoadedOf(overlap, newGeneration));
|
outbox.Enqueue(LoadedOf(overlap, newGeneration));
|
||||||
outbox.Enqueue(new LandblockStreamResult.Unloaded(overlap, oldGeneration));
|
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.Equal(new[] { overlap }, applied);
|
||||||
Assert.True(state.IsLoaded(overlap));
|
Assert.True(state.IsLoaded(overlap));
|
||||||
|
|
@ -559,7 +561,7 @@ public class StreamingControllerPriorityApplyTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HardRecenter_DropsStaleOutboxBeforeDeferredApplyBudget()
|
public void HardRecenter_DropsStaleOutboxThroughBoundedAdmissionBeforeCurrentResult()
|
||||||
{
|
{
|
||||||
uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10);
|
uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10);
|
||||||
var outbox = new Queue<LandblockStreamResult>();
|
var outbox = new Queue<LandblockStreamResult>();
|
||||||
|
|
@ -584,7 +586,8 @@ public class StreamingControllerPriorityApplyTests
|
||||||
ulong oldGeneration = loads[0].Generation;
|
ulong oldGeneration = loads[0].Generation;
|
||||||
ctrl.ForceReloadWindow();
|
ctrl.ForceReloadWindow();
|
||||||
loads.Clear();
|
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;
|
ulong newGeneration = loads.Single(load => load.Id == current).Generation;
|
||||||
|
|
||||||
for (int i = 0; i < 12; i++)
|
for (int i = 0; i < 12; i++)
|
||||||
|
|
@ -593,7 +596,8 @@ public class StreamingControllerPriorityApplyTests
|
||||||
oldGeneration));
|
oldGeneration));
|
||||||
outbox.Enqueue(LoadedOf(current, newGeneration));
|
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(new[] { current }, applied);
|
||||||
Assert.Equal(0, ctrl.DeferredApplyBacklog);
|
Assert.Equal(0, ctrl.DeferredApplyBacklog);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue