From 2ff8f844b0a3cf7cf6699e70ec6987950e378008 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 19:39:23 +0200 Subject: [PATCH] perf(streaming): reserve destination reveal capacity Join destination scheduling to the canonical reveal generation, protect its share across every typed frame-budget dimension, and prevent stale work from clearing a replacement reservation. Remove forced incomplete materialization and project retail's centered portal wait cue while the authored tunnel remains active. Tests: Release build clean; 91 focused reservation/reveal tests; full solution 8,158 passed, 5 skipped. Co-authored-by: Codex --- AGENTS.md | 17 +- CLAUDE.md | 17 +- docs/ISSUES.md | 17 +- docs/README.md | 12 +- docs/architecture/acdream-architecture.md | 24 ++- docs/architecture/code-structure.md | 6 +- .../retail-divergence-register.md | 4 +- docs/plans/2026-04-11-roadmap.md | 11 +- docs/plans/2026-05-12-milestones.md | 10 +- .../2026-07-24-modern-runtime-architecture.md | 12 +- ...runtime-slice-e-cost-budgeted-streaming.md | 31 +++- ...2026-07-16-portal-completion-pseudocode.md | 2 +- ...-retail-streaming-retirement-pseudocode.md | 38 ++++ .../LivePresentationComposition.cs | 19 +- .../Composition/SessionPlayerComposition.cs | 3 +- src/AcDream.App/Input/PlayerModeAutoEntry.cs | 6 +- .../Rendering/PortalTunnelPresentation.cs | 34 +++- .../LocalPlayerTeleportController.cs | 75 ++------ .../Streaming/StreamingCompletionQueue.cs | 1 + .../Streaming/StreamingController.cs | 116 +++++++++--- .../Streaming/StreamingWorkBudget.cs | 167 +++++++++++++++--- .../Streaming/WorldRevealCoordinator.cs | 34 +++- .../WorldRevealLifecycleTelemetry.cs | 31 +++- .../Streaming/WorldRevealReadinessBarrier.cs | 5 +- .../UI/PortalWaitNoticeController.cs | 64 +++++++ .../World/LiveEntityHydrationPorts.cs | 2 +- .../LandblockPresentationPipelineTests.cs | 4 +- .../LocalPlayerTeleportControllerTests.cs | 74 ++++---- .../StreamingCompletionQueueTests.cs | 1 + .../Streaming/StreamingWorkBudgetTests.cs | 121 ++++++++++++- .../Streaming/WorldRevealCoordinatorTests.cs | 60 ++++++- .../UI/PortalWaitNoticeControllerTests.cs | 37 ++++ .../StreamingControllerPriorityApplyTests.cs | 45 +++-- 33 files changed, 870 insertions(+), 230 deletions(-) create mode 100644 src/AcDream.App/UI/PortalWaitNoticeController.cs create mode 100644 tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs diff --git a/AGENTS.md b/AGENTS.md index 92f2f0e2..2f652642 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal formula-media/foreground correction passes -3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips. +Release builds; the current performance-program checkpoint passes +3,674 App tests / 3 skips and 8,158 complete-solution tests / 5 skips. **M4 feature work order (paused while the authorized performance program executes):** the six-slice pre-M4 world-interaction completion @@ -140,12 +140,15 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic, lifetime, full-suite, and user-visual gates passed. Process allocation fell 41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved 17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified -typed residency policy/accounting and bounded retained caches. Slice E0–E4 now +typed residency policy/accounting and bounded retained caches. Slice E0–E5 now enforce typed completion admission, generation-scoped old-world quiescence, metered full-window/recenter retirement, and retained cursor-budgeted -render/physics/static/building/EnvCell publication; E5 destination reservation -and reveal-generation joining is next. The physical console is available and -reserved for E6 connected gates. Slices F/G +render/physics/static/building/EnvCell publication. The canonical reveal +generation now owns one exact destination reservation across every typed +budget dimension; incomplete portal destinations stay in the authored tunnel +and receive retail's centered wait cue instead of forced early reveal. The +physical console is available for E6 deterministic and connected closeout. +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 @@ -170,7 +173,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire truth remain correct. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E6+, and #225's lifestone/particle alpha visual gate. Start structural work at `memory/project_gamewindow_decomposition.md` and diff --git a/CLAUDE.md b/CLAUDE.md index f9b121bc..1f9a1f23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. -Release builds; the appraisal formula-media/foreground correction passes -3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips. +Release builds; the current performance-program checkpoint passes +3,674 App tests / 3 skips and 8,158 complete-solution tests / 5 skips. **M4 feature work order (paused while the authorized performance program executes):** the six-slice pre-M4 world-interaction completion @@ -138,12 +138,15 @@ explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic, lifetime, full-suite, and user-visual gates passed. Process allocation fell 41.8–61.9%, GC pause time fell 48.4–54.1%, uncapped CPU/GPU p99 improved 17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified -typed residency policy/accounting and bounded retained caches. Slice E0–E4 now +typed residency policy/accounting and bounded retained caches. Slice E0–E5 now enforce typed completion admission, generation-scoped old-world quiescence, metered full-window/recenter retirement, and retained cursor-budgeted -render/physics/static/building/EnvCell publication; E5 destination reservation -and reveal-generation joining is next. The physical console is available and -reserved for E6 connected gates. Slices F/G +render/physics/static/building/EnvCell publication. The canonical reveal +generation now owns one exact destination reservation across every typed +budget dimension; incomplete portal destinations stay in the authored tunnel +and receive retail's centered wait cue instead of forced early reveal. The +physical console is available for E6 deterministic and connected closeout. +Slices F/G remain behind the explicit ECS decision gate and Slice J behind its separate gameplay-owner gate. Evidence: `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and @@ -168,7 +171,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire truth remain correct. See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and `docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining -R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's +R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E6+, and #225's lifestone/particle alpha visual gate. Start structural work at `memory/project_gamewindow_decomposition.md` and diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0077767a..388e4449 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -44,12 +44,14 @@ What does NOT go here: smooth and physics, collision, and wire state remain correct. - **Build hygiene:** `#228` records 17 clean-Release test-project warnings; production compilation and all tests pass. -- **Performance side track:** Slices A–D and Slice E0–E4 are complete. Typed +- **Performance side track:** Slices A–D and Slice E0–E5 are complete. Typed completion admission, immediate generation-scoped old-world quiescence, and budgeted full-window/recenter retirement are live. Publication now advances - from retained render/physics/static/building/EnvCell cursors without replay; - E5 destination reservation/reveal joining is next. The physical console is - available and remains reserved for the E6 connected gate. Evidence: + from retained render/physics/static/building/EnvCell cursors without replay. + The canonical reveal generation now owns the destination reservation across + every typed budget dimension; incomplete worlds remain in portal space with + retail's centered wait cue. The physical console is available for E6 + deterministic and connected closeout. Evidence: [`research/2026-07-24-slice-d-unified-residency-report.md`](research/2026-07-24-slice-d-unified-residency-report.md). The [documentation map](README.md) defines how this tactical ledger relates to @@ -3507,7 +3509,12 @@ Tests: `DungeonStreamingGateTests` (4), `TeleportArrivalRulesTests` (4). Registe **Files:** `GameWindow.cs` `OnLivePositionUpdated` (drop stale center) + `TeleportArrivalReadiness`; `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules`); `src/AcDream.App/Streaming/DungeonStreamingGate.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`RemoveLandblock` CurrCell clear + NO-LANDBLOCK verbatim). -**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly. Verify the dungeon→outdoor exit completes promptly (log `teleport complete`, not `teleport HOLD gave up … force-snapping`) with `streaming: dungeon EXIT-expand`, the outdoor world fully streamed, collision working. Also re-checks **#138**. +**Acceptance:** run into a portal, run out, re-enter — repeatedly in one +session, each time placing + streaming correctly. Verify the dungeon→outdoor +exit remains in portal space until canonical destination readiness (with the +centered retail wait cue if it exceeds five seconds), then completes with +`streaming: dungeon EXIT-expand`, the outdoor world fully streamed, and +collision working. Also re-checks **#138**. --- diff --git a/docs/README.md b/docs/README.md index 0c7e2e2b..2dcbaf1b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,15 +14,17 @@ banner cannot silently override the current program state. [`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md). The eight-slice `GameWindow` decomposition and its connected visual matrix are complete. -- **Current active engineering program:** Slices A–D and Slice E0–E4 of +- **Current active engineering program:** Slices A–D and Slice E0–E5 of [`plans/2026-07-24-modern-runtime-architecture.md`](plans/2026-07-24-modern-runtime-architecture.md) are complete. Production world-mesh streaming consumes the validated prepared package, typed residency is bounded/accounted, and streaming now has typed admission plus immediate old-generation quiescence, metered retirement, and retained cursor-budgeted publication across render, physics, - statics, building registries, collision/reflood, and EnvCells. E5 destination - reservation/reveal joining is next. The physical console is available; its - connected performance and visual evidence remains the E6 gate. See + statics, building registries, collision/reflood, and EnvCells. The canonical + reveal generation now owns an exact destination reservation across every + typed budget dimension; incomplete destinations remain in portal space and + receive retail's centered wait cue instead of forced early reveal. The + physical console is available for E6 deterministic/connected closeout. See the [Slice D residency report](research/2026-07-24-slice-d-unified-residency-report.md). - **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and the final two-client portal-out/materialization observer comparison (`#218`). @@ -34,7 +36,7 @@ banner cannot silently override the current program state. user decision gate; Linux/headless automation (Track LH) remains future work. - **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34, and UN 5 — plus the retained retired TS-37 history note. -- **Latest automated baseline:** Release build succeeds; 8,152 tests pass and +- **Latest automated baseline:** Release build succeeds; 8,158 tests pass and five are intentionally skipped. The capped, uncapped, and dense-Arwic physical prepared-asset routes all completed their portal/input/lifetime assertions and disconnected gracefully. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index a859daa3..d8782bc6 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -717,10 +717,16 @@ single-consumer peek/read source so the update thread prices a result before adopting it. `StreamingController` is the sole scheduler: it admits through the typed meter into reusable destination/control/unload/Near/Far FIFOs, preserves 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. The canonical reveal generation +supplies one exact destination cell/radius reservation; the meter protects its +configured share across time, completion admissions, retained CPU bytes, +entity operations, requested GPU bytes, and GL-retirement admissions. A +completion retains the reveal generation that classified it, so a stale +completion or teardown cannot consume or clear a replacement generation's +reservation. Priority changes order and reserved share, never the global budget. Lifecycle artifacts publish worker and per-class backlog, retained -bytes/age, yields, oversizes, overruns, and pending publication/retirement -facts. +bytes/age, per-lane work, yields, oversizes, overruns, and pending +publication/retirement facts. At a hard login/portal reveal edge, `WorldGenerationQuiescence` makes the old generation unavailable through the same `WorldRevealCoordinator` lifetime. @@ -748,12 +754,16 @@ pending publication. `WorldRevealCoordinator` is the single App-layer owner of each login or portal reveal lifetime. It composes the canonical `WorldRevealReadinessBarrier` with -generation-scoped lifecycle observation, so begin, preparation, readiness, -materialization, world visibility, completion, and cancellation cannot be -wired independently. The barrier joins `StreamingController`/`GpuWorldState` +generation-scoped lifecycle observation and the streaming destination +reservation, so begin, preparation, readiness, materialization, world +visibility, completion, cancellation, and reserved capacity cannot be wired +independently. The barrier joins `StreamingController`/`GpuWorldState` static-mesh publication, `WbDrawDispatcher` composite-texture warmup, and `PhysicsEngine` destination residency. Login's sky-only gate and portal-space -transit consume the same predicate; they differ only in presentation. +transit consume the same predicate; they differ only in presentation. There is +no timeout that reveals incomplete content: a delayed portal keeps its DAT +tunnel live and, after five seconds, projects retail's centered +`"In Portal Space - Please Wait..."` retained notice until readiness converges. Composite preparation advances after `WbMeshAdapter.Tick` on the render thread, never from the streaming worker. This is acdream's asynchronous equivalent of retail `SmartBox::UseTime` holding position completion while diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 40ea38a6..4715aefc 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -272,7 +272,9 @@ src/AcDream.App/ │ └── SelectionInteractionController.cs # owns selection intents, Use/PickUp, auto-walk deferral ├── Streaming/ │ ├── LandblockPresentationPipeline.cs # DAT build/apply/retire transaction and resource publication -│ └── ... # shipped streamer/world-state/reveal owners +│ ├── StreamingController.cs # typed frame scheduler + reveal destination reservation +│ ├── WorldRevealCoordinator.cs # canonical readiness, quiesce, and reservation lifetime +│ └── ... # shipped streamer/world-state owners ├── Input/ # (already exists) ├── Audio/ # (already exists) └── Plugins/ # (already exists) @@ -451,7 +453,7 @@ useful ordering seam, but its ownership status is **partial**. | World environment | **Complete ownership** | `WorldEnvironmentController` solely owns WorldTime, the loaded DAT sky, current day group, Weather, server synchronization, AdminEnvirons bridging, and debug time/weather cycles. `GameWindow` keeps ABI-compatible readonly aliases to the same clock/weather instances and composes one render source plus the two live-session callbacks. TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. | | Live identity/lifetime | **Complete App integration** | `LiveEntityRuntime` owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. `LiveEntityHydrationController`, `LiveEntityRuntimeTeardownController`, and `LiveEntityNetworkUpdateController` own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (`aa90c646`). | | Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | -| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness and reveal lifetime (`a4ef5788`). The accepted deterministic lifecycle trace did not change after extraction. | +| World reveal | **Complete** | `WorldRevealCoordinator` owns login/portal readiness, generation quiescence, and the exact streaming destination reservation. Login auto-entry and portal completion close only their active generation; stale completion cannot clear its replacement. Delayed portals retain the authored tunnel and centered retail wait notice rather than force-revealing incomplete content. | | Retained gameplay UI | **Mostly complete feature ownership** | `RetailUiRuntime` and focused panel/controllers own layout and behavior. `GameWindow.OnLoad` still performs substantial service composition, which is allowed until the final composition cleanup. | | Selection/interaction | **Complete** | `WorldSelectionQuery` owns read-only picking/classification/range queries; `SelectionInteractionController` owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; `ItemInteractionController` owns ItemHolder policy plus the shared retail inventory-request transaction. `GameWindow` retains construction and narrow lifecycle forwarding only. | | Landblock presentation | **Complete** | `LandblockBuildFactory` owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and `LandblockPresentationPipeline` own publication and exact retryable retirement. `StreamingOriginRecenterCoordinator` serializes old-window retirement with teleport/session origin lifetimes. `GameWindow` retains construction and one pipeline field only (`c79d0a49`, closeout `4a205a3e`). | diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index d4eac512..30977ddf 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -68,7 +68,7 @@ accepted-divergence entries (#96, #49, #50). |---|---|---|---|---|---| | AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` | | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | +| AD-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 required 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 unhydratable-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. **Slice E5 refinement (2026-07-24):** the reveal generation owns one exact destination reservation across every typed budget dimension. Stale completion cannot consume or clear its replacement, and hydratable incomplete content is never force-revealed; portal transit retains the DAT tunnel and centered retail wait cue until readiness converges. The 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/Streaming/StreamingController.cs`; `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs`; `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 remains in the authored tunnel and presents the centered wait cue after five seconds. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::UseTime` 0x004D6E30; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | @@ -212,7 +212,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | | ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | -| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | +| AP-115 | The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live, but the separate `ClientUISystem` enter/exit sound enums are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, and centered wait notice, but lacks retail's short UI enter/exit cue sounds. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | | AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a9bc76f0..5108b0d8 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1613,11 +1613,14 @@ port in any phase — no separate listing here. > generation unavailable at the hard reveal edge and freezes its world > simulation/audio while full-window and shared-origin teardown advance through > metered resident, entity, and owner-stage cursors with exact retry receipts. -> E4 now retains the exact accepted queue head and cursor-budgets render, +> E4 retains the exact accepted queue head and cursor-budgets render, > physics, static, building-registry, EnvCell, collision, and reflood -> publication before one observer-atomic spatial swap. E5 destination -> reservation/reveal joining is next. F/G and J retain their explicit approval -> gates. +> publication before one observer-atomic spatial swap. E5 now joins the +> canonical reveal generation to an exact destination reservation, protects +> its share across all typed budget dimensions, removes forced incomplete +> materialization, and presents retail's centered five-second portal wait cue. +> E6 deterministic pressure and physical-local closeout is next. F/G and J +> retain their explicit approval gates. **Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the umbrella design — read it first). **Goal:** smoothness first (no frame over diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 5dc109d9..08cea86c 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -93,13 +93,17 @@ The separately authorized modern-runtime performance program has completed Slices A–D: corrected measurement, prepared-package bake/dedup, package-only production streaming, and typed unified residency. Its physical capped, uncapped, and dense connected gates pass without a visual-quality change. -Slice E0–E4 now define and enforce typed completion admission through explicit +Slice E0–E5 now define and enforce typed completion admission through explicit stable priority queues, publish an immediate generation-scoped old-world quiesce at login/portal boundaries, and advance full-window/recenter teardown through exact metered retirement cursors. Render, physics, static, building, and EnvCell publication now resume from retained exact cursors under the same -frame budget. E5 destination reservation and reveal-generation joining is -next. Slices F/G remain behind their explicit ECS approval gate, and Slice J +frame budget. The reveal coordinator now owns the exact destination +generation/cell/radius reservation across every budget dimension; portal +transit never force-reveals incomplete content and surfaces retail's centered +wait cue after five seconds. E6 deterministic pressure and physical-local +closeout is next. Slices F/G remain behind their explicit ECS approval gate, +and Slice J remains behind its gameplay-owner approval gate. Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index c695c213..b15a6aeb 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,7 +2,7 @@ **Date:** 2026-07-24 -**Status:** Slices A–D complete. Slice E is active (E0–E4 complete). +**Status:** Slices A–D complete. Slice E is active (E0–E5 complete; E6 gates next). **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. @@ -729,9 +729,13 @@ checkpoint retained staged or retiring bytes. Evidence: **Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current owner inventory. E1 landed the validated work profile, deterministic meter and completion charge model. E2 cut execution over to stable priority FIFOs. E3 -added immediate generation quiescence and cursored retirement. E4 now advances -render/physics/static publication from retained exact receipts; E5 destination -reservation and reveal-generation joining is next. +added immediate generation quiescence and cursored retirement. E4 advances +render/physics/static publication from retained exact receipts. E5 replaced +mutable priority-radius state with the reveal coordinator's exact +generation-scoped destination reservation, protected its share across every +typed budget dimension, removed forced early materialization, and added +retail's centered five-second portal wait cue. E6 deterministic pressure and +physical-local closeout is next. **Purpose:** Remove update-thread portal transactions. diff --git a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md index 0548cbb9..f2e49fc1 100644 --- a/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md +++ b/docs/plans/2026-07-24-modern-runtime-slice-e-cost-budgeted-streaming.md @@ -1,6 +1,6 @@ # Modern Runtime Slice E — Cost-budgeted streaming and retirement -**Status:** active — E0/E1/E2/E3/E4 complete; E5 destination reservation next +**Status:** active — E0/E1/E2/E3/E4/E5 complete; E6 gates and closeout next **Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md` **Baseline:** Slice D closeout commit `66690805` **Behavior contract:** no visual-quality or retail-behavior reduction @@ -356,6 +356,35 @@ performance and visual evidence remains an E6 gate. five seconds. - Remove the last count-only execution/bypass paths. +**Complete 2026-07-24.** `WorldRevealCoordinator` now owns one exact +generation/cell/radius reservation from login or portal begin through complete +or cancel. `StreamingController` no longer exposes a mutable priority +landblock/radius pair: accepted work records the reveal generation that +classified it, stale completion cannot consume a replacement generation's +reserved lane, and stale teardown cannot clear the replacement reservation. +The frame meter records destination and non-destination use independently and, +while a reveal is active, protects the configured destination share across +wall time, completion admissions, adopted CPU bytes, entity operations, +requested GPU bytes, and GL-retirement admissions. Destination work still +uses the same global ceilings and the existing first-indivisible-operation +progress diagnostic; it does not regain a budget bypass. + +The former ten-second forced materialization path is deleted. An incomplete +destination remains in the authored portal scene until the canonical render, +composite, and collision predicate is true. After five seconds the retained +gameplay UI displays retail's centered +`"In Portal Space - Please Wait..."` notice and lifecycle telemetry records the +cue once; the tunnel continues its DAT animation and repeats the notice on the +retail camera-rotation cadence. Login completion now ends its reveal +reservation/quiescence at the same auto-entry edge that exposes the ready +world. + +Validation: 91 focused reservation/reveal/teleport/UI tests passed; the +complete App suite passed 3,674 / 3 skipped; the complete Release solution +passed 8,158 / 5 skipped; Release solution build green with zero warnings. +The physical-local routes and final performance/resource assertions remain +E6, not E5. + ### E6 — Gates and closeout - Deterministic pressure, reentrancy, failure, cancellation, stale-generation, diff --git a/docs/research/2026-07-16-portal-completion-pseudocode.md b/docs/research/2026-07-16-portal-completion-pseudocode.md index bf1ae775..978ef002 100644 --- a/docs/research/2026-07-16-portal-completion-pseudocode.md +++ b/docs/research/2026-07-16-portal-completion-pseudocode.md @@ -109,7 +109,7 @@ Required asynchronous equivalent: ```text WorldRevealReady(destination): if destination claim cannot be hydrated: - return true so the existing loud forced-placement path diagnoses it + return true so the loud unhydratable-placement path diagnoses it if destination is indoor: return render center landblock Near-tier and ready diff --git a/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md b/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md index 7b9a4c7b..00d17df4 100644 --- a/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md +++ b/docs/research/2026-07-24-retail-streaming-retirement-pseudocode.md @@ -9,6 +9,7 @@ - `CObjectMaint::DestroyObjects @ 0x00508C30` - `CObjectMaint::AddObjectToBeDestroyed @ 0x00508F70` - `SmartBox::UseTime @ 0x00455410` +- `gmSmartBoxUI::UseTime @ 0x004D6E30` The Sept 2013 named pseudo-C is the behavioral oracle. WorldBuilder has no equivalent live-client teardown scheduler; its extracted mesh/cache owners are @@ -125,6 +126,37 @@ object maintenance, physics, landscape, game time, or ambient sound. UI/event dispatch and the portal presentation continue. This is the retail basis for Slice E's immediate old-generation quiesce. +## `gmSmartBoxUI::UseTime` portal wait presentation + +The portal `CreatureMode` remains live while `SmartBox` is blocked. Its camera +continues one random roll segment at a time: + +```text +if now >= rotationStart + rotationDuration: + currentAngle = rotationEndAngle + rotationStart = now + rotationDuration = RandomUniform(0.6, 1.8) seconds + rotationStartAngle = currentAngle + rotationEndAngle = RandomUniform(0, 360) degrees + ECM_UI.SendNotice_DisplayStringInfo( + channel = 0x1A, + text = "In Portal Space - Please Wait...") +else: + level = UIGlobals.GetAnimLevel( + (now - rotationStart) / rotationDuration) + currentAngle = Lerp( + rotationStartAngle, + rotationEndAngle, + level / 1024) + +CreatureMode.SetCameraDirection_Degrees((0, currentAngle, 0)) +``` + +The notice is a centered UI display string, not chat. Slice E's asynchronous +adaptation delays its first projection until the recorded five-second reveal +stall threshold, then repeats it on the same rotation-segment boundary while +the authored tunnel keeps animating. + ## Acdream asynchronous adaptation Retail performs each teardown synchronously and can synchronously block cell @@ -156,6 +188,12 @@ reveal only when the one destination-generation readiness state proves: required Near scene ready required composite textures ready camera/player identity belongs to destination generation + +if the portal reveal is still incomplete after five seconds: + keep the DAT-authored portal scene animating + display the centered retail wait notice + emit one generation-scoped diagnostic + do not materialize early ``` Release-later never means run-later: an old owner may retain memory until its diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index ea11f461..0d04b40d 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -829,6 +829,20 @@ internal sealed class LivePresentationCompositionPhase "portal depth mask", () => new PortalDepthMaskRenderer(d.Gl), static value => value.Dispose()); + CompositionAcquisitionScope.CompositionAcquisitionLease< + PortalWaitNoticeController>? portalWaitNoticeLease = null; + if (interaction.RetainedUi is { } portalRetainedUi) + { + portalWaitNoticeLease = scope.Acquire( + "portal wait notice", + () => new PortalWaitNoticeController( + portalRetainedUi.Host.Root), + static value => value.Dispose()); + } + Action? displayPortalWaitNotice = + portalWaitNoticeLease is { } waitNoticeLease + ? waitNoticeLease.Resource.Set + : null; PortalTunnelPresentation portalTunnel; try { @@ -840,7 +854,9 @@ internal sealed class LivePresentationCompositionPhase d.HookRouter, dispatcherLease.Resource, foundation.SceneLighting, - foundation.MeshAdapter), + foundation.MeshAdapter, + displayPortalWaitNotice, + portalWaitNoticeLease?.Resource), static tunnel => tunnel.PrepareResources()); } catch (Exception acquisitionFailure) @@ -1006,6 +1022,7 @@ internal sealed class LivePresentationCompositionPhase clipFrameLease.Transfer(); portalDepthLease.Transfer(); portalTunnelLease.Transfer(); + portalWaitNoticeLease?.Transfer(); skyLease.Transfer(); skyShaderLease.Transfer(); particleLease.Transfer(); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 64a1f4c8..098a9a12 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -339,7 +339,8 @@ internal sealed class SessionPlayerCompositionPhase live.DrawDispatcher.InvalidateCompositeWarmupReadiness, spawnClaimClassifier.IsUnhydratable, d.Log, - worldQuiescence); + worldQuiescence, + streaming); Fault(SessionPlayerCompositionPoint.WorldRevealCreated); return CompleteSessionPlayer( diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index cdf0837a..065c3839 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -94,7 +94,11 @@ internal sealed class LivePlayerModeAutoEntryContext public bool IsPlayerModeActive => _mode.IsPlayerMode; - public void EnterPlayerMode() => _playerMode.EnterFromAutoEntry(); + public void EnterPlayerMode() + { + _playerMode.EnterFromAutoEntry(); + _worldReveal.Complete(); + } } public sealed class PlayerModeAutoEntry diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index aeb4227b..b9562f75 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -53,7 +53,8 @@ public sealed class PortalTunnelPresentation : IDisposable private readonly WorldEntity _entity; private readonly PortalTunnelCamera _camera = new(); private readonly Random _random; - private readonly Action? _displayNotice; + private readonly Action? _displayNotice; + private IDisposable? _displayNoticeLifetime; private readonly PortalMeshReferenceOwner _meshReferences; private bool _visible; @@ -62,6 +63,7 @@ public sealed class PortalTunnelPresentation : IDisposable private float _rotationStartAngle; private float _rotationEndAngle; private float _rotationCurrentAngle; + private bool _waitCueVisible; private bool _disposeRequested; private bool _disposing; private bool _disposed; @@ -77,7 +79,8 @@ public sealed class PortalTunnelPresentation : IDisposable IAnimationLoader animationLoader, IAnimationHookSink hookSink, Random random, - Action? displayNotice) + Action? displayNotice, + IDisposable? displayNoticeLifetime) { _gl = gl; _dispatcher = dispatcher; @@ -90,6 +93,7 @@ public sealed class PortalTunnelPresentation : IDisposable _sequence.HookObj = _animationHooks; _random = random; _displayNotice = displayNotice; + _displayNoticeLifetime = displayNoticeLifetime; _entity = new WorldEntity { @@ -119,7 +123,8 @@ public sealed class PortalTunnelPresentation : IDisposable WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IWbMeshAdapter meshAdapter, - Action? displayNotice = null, + Action? displayNotice = null, + IDisposable? displayNoticeLifetime = null, Random? random = null) { ArgumentNullException.ThrowIfNull(gl); @@ -154,7 +159,8 @@ public sealed class PortalTunnelPresentation : IDisposable animationLoader, hookSink, random ?? Random.Shared, - displayNotice); + displayNotice, + displayNoticeLifetime); } internal static void EnsureRequiredAssets( @@ -211,6 +217,7 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationEndAngle = 0f; _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; + SetWaitCue(false); _visible = true; RebuildPose(); } @@ -221,6 +228,7 @@ public sealed class PortalTunnelPresentation : IDisposable if (_disposed) return; _visible = false; + SetWaitCue(false); _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -236,6 +244,16 @@ public sealed class PortalTunnelPresentation : IDisposable TickRotation(dt); } + public void SetWaitCue(bool visible) + { + if (_waitCueVisible == visible) + return; + + _waitCueVisible = visible; + _displayNotice?.Invoke( + visible ? "In Portal Space - Please Wait..." : null); + } + /// /// Draw retail portal space into the active viewport. The caller suppresses /// the normal world viewport while this scene is visible, then draws the @@ -298,7 +316,8 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax); _rotationStartAngle = _rotationCurrentAngle; _rotationEndAngle = (float)NextDouble(0.0, 360.0); - _displayNotice?.Invoke("In Portal Space - Please Wait..."); + if (_waitCueVisible) + _displayNotice?.Invoke("In Portal Space - Please Wait..."); } else { @@ -389,11 +408,16 @@ public sealed class PortalTunnelPresentation : IDisposable try { _visible = false; + SetWaitCue(false); _animationHooks.Clear(); _sequence.ClearAnimations(); _meshReferences.Dispose(); if (_meshReferences.IsDisposed) + { + _displayNoticeLifetime?.Dispose(); + _displayNoticeLifetime = null; _disposed = true; + } } finally { diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 05e8a7df..e5d6e0f2 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -127,8 +127,6 @@ internal interface ILocalPlayerTeleportStreamingOperations bool BeginRecenter(int x, int y, bool isSealedDungeon); bool ResetRecenter(bool sessionEnding); bool IsSealedDungeon(uint cellId); - void SetPriority(uint landblockId, int radius); - void ClearPriority(); } internal sealed class LocalPlayerTeleportStreamingOperations @@ -165,22 +163,11 @@ internal sealed class LocalPlayerTeleportStreamingOperations public bool IsSealedDungeon(uint cellId) => _sealedDungeonCells.IsSealedDungeon(cellId); - public void SetPriority(uint landblockId, int radius) - { - _streaming.PriorityLandblockId = landblockId; - _streaming.PriorityRadius = radius; - } - - public void ClearPriority() - { - _streaming.PriorityLandblockId = 0u; - _streaming.PriorityRadius = 0; - } } internal interface ILocalPlayerTeleportPlacement { - void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced); + void Place(Vector3 position, uint cellId, Quaternion rotation); } /// @@ -218,7 +205,7 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme _spatial = spatial ?? throw new ArgumentNullException(nameof(spatial)); } - public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced) + public void Place(Vector3 position, uint cellId, Quaternion rotation) { PlayerMovementController controller = _controller.Controller ?? throw new InvalidOperationException( @@ -233,13 +220,6 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme resolved.Position.Y, resolved.Position.Z); - if (forced) - { - Console.WriteLine( - $"live: teleport HOLD gave up (impossible/timeout) - force-snapping " - + $"cell=0x{cellId:X8} pos={position} -> 0x{resolved.CellId:X8} {snapped}"); - } - uint playerGuid = _identity.ServerGuid; controller.SetPosition( snapped, @@ -270,7 +250,7 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme PhysicsDiagnostics.LogTeleport( "PLACED", controller.CellId, - $"forced={forced}"); + "readiness=complete"); Console.WriteLine( $"live: teleport materialized - snapped to {controller.Position} " + $"cell=0x{controller.CellId:X8}"); @@ -315,6 +295,7 @@ internal interface ILocalPlayerTeleportPresentation : IDisposable void TickTunnel(float deltaSeconds); void EnterTunnel(); void ExitTunnel(); + void SetWaitCue(bool visible); void Reset(); Matrix4x4 ApplyViewPlane(Matrix4x4 projection); ICamera ApplyViewPlane(ICamera camera); @@ -354,6 +335,7 @@ internal sealed class LocalPlayerTeleportPresentation public void TickTunnel(float deltaSeconds) => _tunnel.Tick(deltaSeconds); public void EnterTunnel() => _tunnel.Enter(); public void ExitTunnel() => _tunnel.Exit(); + public void SetWaitCue(bool visible) => _tunnel.SetWaitCue(visible); public void Reset() { @@ -384,10 +366,6 @@ internal sealed class LocalPlayerTeleportController AcDream.App.Interaction.ISelectionViewPlaneSource, IDisposable { - internal const float MaximumWorldHoldSeconds = 10f; - internal const int NearRingRadius = - WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius; - private readonly TeleportTransitCoordinator _transit = new(); private readonly ILocalPlayerTeleportAuthority _authority; private readonly ILocalPlayerTeleportInputLifetime _input; @@ -404,7 +382,6 @@ internal sealed class LocalPlayerTeleportController private bool _hasAcceptedDestination; private WorldSession.EntityPositionUpdate _acceptedDestination; private float _holdSeconds; - private bool _forced; private long _lifetimeGeneration; private bool _disposed; @@ -496,15 +473,13 @@ internal sealed class LocalPlayerTeleportController if (!IsCurrentLifetime(generation, sequence)) return; - if (haveDestination && originReady && !ready) - { + if (haveDestination && !ready) _holdSeconds += deltaSeconds; - if (_holdSeconds >= MaximumWorldHoldSeconds) - { - ready = true; - _forced = true; - } - } + _presentation.SetWaitCue( + haveDestination + && !ready + && _worldReveal.ObserveWait( + TimeSpan.FromSeconds(_holdSeconds))); var (_, events) = _presentation.Tick(deltaSeconds, ready); if (!IsCurrentLifetime(generation, sequence)) @@ -518,14 +493,10 @@ internal sealed class LocalPlayerTeleportController _placement.Place( _pendingPosition, _pendingCell, - _pendingRotation, - _forced); + _pendingRotation); if (!IsCurrentLifetime(generation, sequence)) return; _worldReveal.ObserveMaterialized(); - if (!IsCurrentLifetime(generation, sequence)) - return; - _streaming.ClearPriority(); if (!IsCurrentLifetime(generation, sequence)) return; break; @@ -590,7 +561,6 @@ internal sealed class LocalPlayerTeleportController } _holdSeconds = 0f; - _forced = false; _presentation.Begin(_mode.Projection); if (_lifetimeGeneration != generation || !_transit.HasPendingStart) return; @@ -677,7 +647,7 @@ internal sealed class LocalPlayerTeleportController // 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); + _worldReveal.Begin(WorldRevealKind.Portal, position.LandblockId); if (!IsCurrentLifetime(generation, sequence)) return false; @@ -713,13 +683,6 @@ internal sealed class LocalPlayerTeleportController _pendingPosition = worldPosition; _pendingCell = position.LandblockId; _holdSeconds = 0f; - _forced = false; - _streaming.SetPriority( - StreamingRegion.EncodeLandblockId(landblockX, landblockY), - NearRingRadius); - if (!IsCurrentLifetime(generation, sequence)) - return false; - PhysicsDiagnostics.LogTeleport( "AIM", position.LandblockId, @@ -748,25 +711,19 @@ internal sealed class LocalPlayerTeleportController _hasAcceptedDestination = false; _acceptedDestination = default; _holdSeconds = 0f; - _forced = false; _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) return generation; - if (clearSession) - { - _worldReveal.Cancel(); - if (_lifetimeGeneration != generation) - return generation; - } + _worldReveal.Cancel(); + if (_lifetimeGeneration != generation) + return generation; _presentation.Reset(); if (_lifetimeGeneration != generation) return generation; - _streaming.ClearPriority(); - return generation; } diff --git a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs index 97642e07..6a606cb0 100644 --- a/src/AcDream.App/Streaming/StreamingCompletionQueue.cs +++ b/src/AcDream.App/Streaming/StreamingCompletionQueue.cs @@ -27,6 +27,7 @@ internal readonly record struct StreamingQueuedCompletion( LandblockStreamResult Result, LandblockStreamCostEstimate Estimate, StreamingCompletionPriority Priority, + long RevealGeneration, ulong Generation, long Sequence, long EnqueuedTimestamp); diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 4e3d502d..3f1c3e73 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -17,8 +17,15 @@ namespace AcDream.App.Streaming; /// Threading: not thread-safe. All calls must happen on the render thread. /// /// -public sealed class StreamingController : IStreamingFrameBackend +public sealed class StreamingController + : IStreamingFrameBackend, + IWorldRevealStreamingScheduler { + private readonly record struct DestinationReservation( + long RevealGeneration, + uint LandblockId, + int Radius); + private sealed class OriginRecenterRetirement { public bool RadiiConverged; @@ -61,6 +68,7 @@ public sealed class StreamingController : IStreamingFrameBackend private readonly StreamingCompletionQueue _completionQueue = new(); private long _nextCompletionSequence; private int _legacyCompletionProfile = 4; + private DestinationReservation? _destinationReservation; private readonly GpuWorldState _state; private StreamingRegion? _region; @@ -120,24 +128,54 @@ public sealed class StreamingController : IStreamingFrameBackend } } - /// - /// #138: the teleport destination landblock id. Loaded or promoted results - /// inside its priority ring enter the destination FIFO, ahead of ordinary - /// Near/Far work, but still consume the same typed frame budget. - /// - /// Set by GameWindow when a teleport starts; reset to 0 - /// once the destination landblock has been applied (or when the - /// teleport destination changes). - /// - public uint PriorityLandblockId { get; set; } + internal long ActiveRevealGeneration => + _destinationReservation?.RevealGeneration ?? 0L; - /// - /// Radius in landblocks (Chebyshev distance) around - /// classified as destination work. - /// Zero means only the center. This changes queue priority, never budget - /// enforcement or atomic publication semantics. - /// - public int PriorityRadius { get; set; } + internal uint DestinationLandblockId => + _destinationReservation?.LandblockId ?? 0u; + + internal int DestinationRadius => + _destinationReservation?.Radius ?? 0; + + internal void BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius) + { + if (revealGeneration <= 0) + throw new ArgumentOutOfRangeException(nameof(revealGeneration)); + if (destinationCell == 0u) + throw new ArgumentOutOfRangeException(nameof(destinationCell)); + if (requiredRenderRadius < 0) + throw new ArgumentOutOfRangeException(nameof(requiredRenderRadius)); + + _destinationReservation = new DestinationReservation( + revealGeneration, + (destinationCell & 0xFFFF0000u) | 0xFFFFu, + requiredRenderRadius); + } + + internal void EndDestinationReservation(long revealGeneration) + { + if (_destinationReservation is { } active + && active.RevealGeneration == revealGeneration) + { + _destinationReservation = null; + } + } + + void IWorldRevealStreamingScheduler.BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius) => + BeginDestinationReservation( + revealGeneration, + destinationCell, + requiredRenderRadius); + + void IWorldRevealStreamingScheduler.EndDestinationReservation( + long revealGeneration) => + EndDestinationReservation(revealGeneration); // [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing // accepted completion backlog. A non-zero value during/after a teleport is @@ -507,6 +545,11 @@ public sealed class StreamingController : IStreamingFrameBackend for (int i = 0; i < pending.Count; i++) { LandblockStreamResult result = pending[i]; + using StreamingWorkMeter.LaneScope lane = + _activeWorkMeter.EnterLane( + IsDestinationWork(result.LandblockId) + ? StreamingWorkLane.Destination + : StreamingWorkLane.NonDestination); LandblockPublicationAdvance advance = _presentation.ResumePublication( result, @@ -549,7 +592,9 @@ public sealed class StreamingController : IStreamingFrameBackend throw new InvalidOperationException( "StreamingController.Tick cannot be reentered."); - var meter = new StreamingWorkMeter(_workBudget); + var meter = new StreamingWorkMeter( + _workBudget, + destinationReservationActive: _destinationReservation is not null); _activeWorkMeter = meter; try { @@ -901,15 +946,13 @@ public sealed class StreamingController : IStreamingFrameBackend } /// - /// True when is the priority center or within - /// landblocks of it (Chebyshev) — i.e. inside the teleport - /// near ring that prioritizes. With the default radius 0 - /// this reduces to an exact match on (the original - /// single-landblock priority behaviour). + /// True when belongs to the canonical reveal + /// generation's destination neighborhood. /// - private bool IsWithinPriorityRing(uint id) - => PriorityLandblockId != 0u - && ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius; + private bool IsDestinationWork(uint id) + => _destinationReservation is { } reservation + && ChebyshevLandblocks(id, reservation.LandblockId) + <= reservation.Radius; /// /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full @@ -1498,6 +1541,11 @@ public sealed class StreamingController : IStreamingFrameBackend "The completion queue returned a null head."); try { + using StreamingWorkMeter.LaneScope lane = meter.EnterLane( + work.Priority == StreamingCompletionPriority.Destination + && work.RevealGeneration == ActiveRevealGeneration + ? StreamingWorkLane.Destination + : StreamingWorkLane.NonDestination); LandblockPublicationAdvance advance = ApplyResult( work, meter, @@ -1554,6 +1602,9 @@ public sealed class StreamingController : IStreamingFrameBackend "The completion source returned a null peek."); bool stale = IsStaleGeneration(result) && !_presentation.HasPendingPublication(result); + StreamingCompletionPriority priority = stale + ? StreamingCompletionPriority.Control + : ClassifyCompletion(result); LandblockStreamCostEstimate estimate = LandblockStreamResultCost.Estimate(result); StreamingWorkCost admissionCost = stale @@ -1563,6 +1614,10 @@ public sealed class StreamingController : IStreamingFrameBackend estimate.Work.CompletionAdmissions, AdoptedCpuBytes: estimate.Work.AdoptedCpuBytes); + using StreamingWorkMeter.LaneScope lane = meter.EnterLane( + priority == StreamingCompletionPriority.Destination + ? StreamingWorkLane.Destination + : StreamingWorkLane.NonDestination); StreamingWorkAdmission admission = meter.TryReserve( admissionCost, "completion-admission"); @@ -1584,7 +1639,10 @@ public sealed class StreamingController : IStreamingFrameBackend _completionQueue.Enqueue(new StreamingQueuedCompletion( result, estimate, - ClassifyCompletion(result), + priority, + priority == StreamingCompletionPriority.Destination + ? ActiveRevealGeneration + : 0L, result.Generation, checked(_nextCompletionSequence++), Stopwatch.GetTimestamp())); @@ -1604,7 +1662,7 @@ public sealed class StreamingController : IStreamingFrameBackend { if (result is LandblockStreamResult.Loaded or LandblockStreamResult.Promoted - && IsWithinPriorityRing(result.LandblockId)) + && IsDestinationWork(result.LandblockId)) { return StreamingCompletionPriority.Destination; } diff --git a/src/AcDream.App/Streaming/StreamingWorkBudget.cs b/src/AcDream.App/Streaming/StreamingWorkBudget.cs index cfcd85eb..04d6c242 100644 --- a/src/AcDream.App/Streaming/StreamingWorkBudget.cs +++ b/src/AcDream.App/Streaming/StreamingWorkBudget.cs @@ -135,11 +135,19 @@ public enum StreamingWorkLimit : byte GlRetireOperations, } +internal enum StreamingWorkLane : byte +{ + NonDestination, + Destination, +} + /// /// Immutable observation of one frame's streaming work. /// public readonly record struct StreamingWorkMeterSnapshot( StreamingWorkCost Used, + StreamingWorkCost DestinationUsed, + StreamingWorkCost NonDestinationUsed, int Operations, int CompletedOperations, int YieldCount, @@ -177,7 +185,14 @@ public sealed class StreamingWorkMeter private readonly Func _timestamp; private readonly long _frequency; private readonly long _start; + private readonly bool _destinationReservationActive; private StreamingWorkCost _used; + private StreamingWorkCost _destinationUsed; + private StreamingWorkCost _nonDestinationUsed; + private StreamingWorkLane _lane; + private long _destinationElapsedTicks; + private long _nonDestinationElapsedTicks; + private long _activeOperationStart; private int _operations; private int _completed; private int _yields; @@ -191,15 +206,22 @@ public sealed class StreamingWorkMeter private string? _lastStage; private StreamingWorkLimit _lastLimit; - public StreamingWorkMeter(StreamingWorkBudget budget) - : this(budget, Stopwatch.GetTimestamp, Stopwatch.Frequency) + public StreamingWorkMeter( + StreamingWorkBudget budget, + bool destinationReservationActive = false) + : this( + budget, + Stopwatch.GetTimestamp, + Stopwatch.Frequency, + destinationReservationActive) { } public StreamingWorkMeter( StreamingWorkBudget budget, Func timestamp, - long timestampFrequency) + long timestampFrequency, + bool destinationReservationActive = false) { ArgumentNullException.ThrowIfNull(timestamp); if (timestampFrequency <= 0) @@ -210,6 +232,18 @@ public sealed class StreamingWorkMeter _timestamp = timestamp; _frequency = timestampFrequency; _start = timestamp(); + _destinationReservationActive = destinationReservationActive; + } + + internal LaneScope EnterLane(StreamingWorkLane lane) + { + if (_reservationActive) + throw new InvalidOperationException( + "Cannot change streaming lane during an active operation."); + + StreamingWorkLane previous = _lane; + _lane = lane; + return new LaneScope(this, previous); } public StreamingWorkAdmission TryReserve( @@ -237,9 +271,14 @@ public sealed class StreamingWorkMeter } _used = _used.Add(cost); + if (_lane == StreamingWorkLane.Destination) + _destinationUsed = _destinationUsed.Add(cost); + else + _nonDestinationUsed = _nonDestinationUsed.Add(cost); _operations++; _reservationActive = true; _activeStage = stage; + _activeOperationStart = _timestamp(); if (ensureProgress) _ensuredProgressGranted = true; if (limit != StreamingWorkLimit.None) @@ -258,31 +297,10 @@ public sealed class StreamingWorkMeter return StreamingWorkAdmission.Admitted; } - /// - /// E1 observation seam: records work the legacy scheduler already chose to - /// execute, while naming the budget decision that would have yielded. This - /// preserves execution until the explicit scheduler takes ownership in E2. - /// - public void ForceReserveObserved( - StreamingWorkCost cost, - string stage) - { - cost.Validate(); - ArgumentException.ThrowIfNullOrWhiteSpace(stage); - if (_reservationActive) - throw new InvalidOperationException( - "The prior streaming operation has not completed."); - - _used = _used.Add(cost); - _operations++; - _reservationActive = true; - _activeStage = stage; - _lastStage = stage; - } - public void Complete() { EnsureReservation(); + CompleteLaneTiming(); _completed++; _reservationActive = false; _activeStage = null; @@ -292,6 +310,7 @@ public sealed class StreamingWorkMeter public void Fail() { EnsureReservation(); + CompleteLaneTiming(); _failures++; _reservationActive = false; _activeStage = null; @@ -313,6 +332,8 @@ public sealed class StreamingWorkMeter long now = _timestamp(); return new StreamingWorkMeterSnapshot( _used, + _destinationUsed, + _nonDestinationUsed, _operations, _completed, _yields, @@ -348,9 +369,105 @@ public sealed class StreamingWorkMeter { return StreamingWorkLimit.GlRetireOperations; } + + if (_destinationReservationActive + && _lane == StreamingWorkLane.NonDestination) + { + double unreservedFraction = + 1.0 - _budget.DestinationReserveFraction; + long maxTimeTicks = ReservedLimit( + _budget.MaxUpdateTime.Ticks, + unreservedFraction); + if (_nonDestinationElapsedTicks >= maxTimeTicks) + return StreamingWorkLimit.Time; + if ((long)_nonDestinationUsed.CompletionAdmissions + + cost.CompletionAdmissions + > ReservedLimit( + _budget.MaxCompletionAdmissions, + unreservedFraction)) + { + return StreamingWorkLimit.CompletionAdmissions; + } + if (_nonDestinationUsed.AdoptedCpuBytes + > ReservedLimit( + _budget.MaxAdoptedCpuBytes, + unreservedFraction) - cost.AdoptedCpuBytes) + { + return StreamingWorkLimit.AdoptedCpuBytes; + } + if ((long)_nonDestinationUsed.EntityOperations + + cost.EntityOperations + > ReservedLimit( + _budget.MaxEntityOperations, + unreservedFraction)) + { + return StreamingWorkLimit.EntityOperations; + } + if (_nonDestinationUsed.GpuUploadBytes + > ReservedLimit( + _budget.MaxGpuUploadBytes, + unreservedFraction) - cost.GpuUploadBytes) + { + return StreamingWorkLimit.GpuUploadBytes; + } + if ((long)_nonDestinationUsed.GlRetireOperations + + cost.GlRetireOperations + > ReservedLimit( + _budget.MaxGlRetireOperations, + unreservedFraction)) + { + return StreamingWorkLimit.GlRetireOperations; + } + } return StreamingWorkLimit.None; } + private static long ReservedLimit(long total, double fraction) => + Math.Max(1L, (long)Math.Floor(total * fraction)); + + private void CompleteLaneTiming() + { + long raw = Math.Max(0L, _timestamp() - _activeOperationStart); + long elapsed = raw > long.MaxValue / TimeSpan.TicksPerSecond + ? long.MaxValue + : raw * TimeSpan.TicksPerSecond / _frequency; + if (_lane == StreamingWorkLane.Destination) + _destinationElapsedTicks = SaturatingAdd( + _destinationElapsedTicks, + elapsed); + else + _nonDestinationElapsedTicks = SaturatingAdd( + _nonDestinationElapsedTicks, + elapsed); + } + + private static long SaturatingAdd(long left, long right) => + left > long.MaxValue - right ? long.MaxValue : left + right; + + private void RestoreLane(StreamingWorkLane lane) + { + if (_reservationActive) + throw new InvalidOperationException( + "Cannot restore streaming lane during an active operation."); + _lane = lane; + } + + internal readonly struct LaneScope : IDisposable + { + private readonly StreamingWorkMeter _meter; + private readonly StreamingWorkLane _previous; + + internal LaneScope( + StreamingWorkMeter meter, + StreamingWorkLane previous) + { + _meter = meter; + _previous = previous; + } + + public void Dispose() => _meter.RestoreLane(_previous); + } + private void EnsureReservation() { if (!_reservationActive) diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index d24c0603..de9ea6a6 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -1,5 +1,20 @@ namespace AcDream.App.Streaming; +/// +/// Generation-scoped destination-work seam. The reveal coordinator is the +/// canonical owner of login/portal lifetime; the streaming scheduler only +/// reserves capacity for the exact generation and destination it is given. +/// +internal interface IWorldRevealStreamingScheduler +{ + void BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius); + + void EndDestinationReservation(long revealGeneration); +} + /// /// Owns one login/portal reveal lifetime across readiness and lifecycle /// diagnostics. This keeps the destination barrier and its observations on a @@ -11,6 +26,7 @@ internal sealed class WorldRevealCoordinator private readonly WorldRevealReadinessBarrier _readiness; private readonly WorldRevealLifecycleTelemetry _lifecycle; private readonly WorldGenerationQuiescence? _quiescence; + private readonly IWorldRevealStreamingScheduler? _streaming; private long _activeGeneration; public WorldRevealCoordinator( @@ -22,7 +38,8 @@ internal sealed class WorldRevealCoordinator Action invalidateCompositeTextures, Func isSpawnClaimUnhydratable, Action? log = null, - WorldGenerationQuiescence? quiescence = null) + WorldGenerationQuiescence? quiescence = null, + IWorldRevealStreamingScheduler? streaming = null) { _readiness = new WorldRevealReadinessBarrier( isRenderNeighborhoodReady, @@ -34,17 +51,26 @@ internal sealed class WorldRevealCoordinator isSpawnClaimUnhydratable); _lifecycle = new WorldRevealLifecycleTelemetry(log); _quiescence = quiescence; + _streaming = streaming; } public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot; public int PortalMaterializationCount => _lifecycle.PortalMaterializationCount; + public bool WaitCueShown => _lifecycle.WaitCueShown; - public long Begin(WorldRevealKind kind) + public long Begin(WorldRevealKind kind, uint destinationCell) { + if (destinationCell == 0u) + throw new ArgumentOutOfRangeException(nameof(destinationCell)); + _readiness.Begin(); long generation = _lifecycle.Begin(kind); _activeGeneration = generation; _quiescence?.Begin(generation); + _streaming?.BeginDestinationReservation( + generation, + destinationCell, + WorldRevealReadinessBarrier.RequiredRenderRadius(destinationCell)); return generation; } @@ -70,6 +96,9 @@ internal sealed class WorldRevealCoordinator public void ObserveWorldViewportVisible() => _lifecycle.ObserveWorldViewportVisible(); + public bool ObserveWait(TimeSpan elapsed) => + _lifecycle.ObserveWait(elapsed); + public void Complete() { _lifecycle.Complete(); @@ -89,6 +118,7 @@ internal sealed class WorldRevealCoordinator return; _activeGeneration = 0; + _streaming?.EndDestinationReservation(generation); _quiescence?.End(generation); } } diff --git a/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs index 95e0e018..d23b589a 100644 --- a/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs +++ b/src/AcDream.App/Streaming/WorldRevealLifecycleTelemetry.cs @@ -30,10 +30,14 @@ internal readonly record struct WorldRevealLifecycleSnapshot( /// internal sealed class WorldRevealLifecycleTelemetry { + internal static readonly TimeSpan RetailWaitCueDelay = + TimeSpan.FromSeconds(5); + private readonly Action _log; private WorldRevealLifecycleSnapshot _snapshot; private long _nextGeneration; private int _portalMaterializationCount; + private bool _waitCueShown; public WorldRevealLifecycleTelemetry(Action? log = null) { @@ -42,6 +46,7 @@ internal sealed class WorldRevealLifecycleTelemetry public WorldRevealLifecycleSnapshot Snapshot => _snapshot; public int PortalMaterializationCount => _portalMaterializationCount; + public bool WaitCueShown => _waitCueShown; public long Begin(WorldRevealKind kind) { @@ -53,6 +58,7 @@ internal sealed class WorldRevealLifecycleTelemetry } long generation = checked(++_nextGeneration); + _waitCueShown = false; _snapshot = new WorldRevealLifecycleSnapshot( generation, kind, @@ -66,6 +72,27 @@ internal sealed class WorldRevealLifecycleTelemetry return generation; } + public bool ObserveWait(TimeSpan elapsed) + { + if (_snapshot.Generation == 0 + || _snapshot.Cancelled + || _snapshot.Completed + || elapsed < RetailWaitCueDelay) + { + return false; + } + + if (!_waitCueShown) + { + _waitCueShown = true; + _log( + $"[world-reveal] event=wait-cue elapsedMs={elapsed.TotalMilliseconds:F0} " + + Describe(_snapshot)); + } + + return true; + } + public void ObserveReadiness(WorldRevealReadinessSnapshot readiness) { if (_snapshot.Generation == 0 || _snapshot.Cancelled) @@ -121,7 +148,9 @@ internal sealed class WorldRevealLifecycleTelemetry public void Cancel() { - if (_snapshot.Generation == 0 || _snapshot.Cancelled) + if (_snapshot.Generation == 0 + || _snapshot.Cancelled + || _snapshot.Completed) return; _snapshot = _snapshot with { Cancelled = true }; diff --git a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs index 428cf912..057b1ec9 100644 --- a/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs +++ b/src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs @@ -94,7 +94,8 @@ internal sealed class WorldRevealReadinessBarrier /// /// True only when the same destination is drawable and collidable. An /// impossible indoor claim intentionally crosses the barrier so the - /// existing loud forced-placement path can expose the invalid server data. + /// existing loud unhydratable-placement path can expose invalid server + /// data. A merely slow hydratable claim never crosses early. /// public bool IsReady(uint destinationCell) => Evaluate(destinationCell).IsReady; @@ -139,7 +140,7 @@ internal sealed class WorldRevealReadinessBarrier collisionReady); } - private static int RequiredRenderRadius(uint destinationCell) => + internal static int RequiredRenderRadius(uint destinationCell) => IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius; private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u; diff --git a/src/AcDream.App/UI/PortalWaitNoticeController.cs b/src/AcDream.App/UI/PortalWaitNoticeController.cs new file mode 100644 index 00000000..957508cc --- /dev/null +++ b/src/AcDream.App/UI/PortalWaitNoticeController.cs @@ -0,0 +1,64 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Retained presentation of retail's ECM_UI display-string notice emitted by +/// gmSmartBoxUI::UseTime @ 0x004D6E30 while portal-space cell blocking +/// continues. It is a centered overlay, not a chat message. +/// +internal sealed class PortalWaitNoticeController : IDisposable +{ + private readonly UiRoot _root; + private readonly UiText _text; + private UiText.Line[] _lines = []; + private bool _disposed; + + public PortalWaitNoticeController(UiRoot root) + { + _root = root ?? throw new ArgumentNullException(nameof(root)); + _text = new UiText + { + Name = "PortalSpaceWaitNotice", + Left = 0f, + Top = 0f, + Width = root.Width, + Height = root.Height, + Anchors = AnchorEdges.Left + | AnchorEdges.Top + | AnchorEdges.Right + | AnchorEdges.Bottom, + Centered = true, + OneLine = true, + ClickThrough = true, + ZOrder = int.MaxValue, + DefaultColor = Vector4.One, + Visible = false, + }; + _text.LinesProvider = () => _lines; + _root.AddChild(_text); + } + + public void Set(string? message) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (string.IsNullOrEmpty(message)) + { + _text.Visible = false; + _lines = []; + return; + } + + _lines = [new UiText.Line(message, Vector4.One)]; + _text.Visible = true; + } + + public void Dispose() + { + if (_disposed) + return; + + _root.RemoveChild(_text); + _disposed = true; + } +} diff --git a/src/AcDream.App/World/LiveEntityHydrationPorts.cs b/src/AcDream.App/World/LiveEntityHydrationPorts.cs index 0948bdac..d11bc854 100644 --- a/src/AcDream.App/World/LiveEntityHydrationPorts.cs +++ b/src/AcDream.App/World/LiveEntityHydrationPorts.cs @@ -224,7 +224,7 @@ internal sealed class LiveEntityWorldOriginCoordinator : ILiveEntityWorldOriginC lbY, isSealedDungeon: _sealedDungeonCells.IsSealedDungeon( position.LandblockId)); - _worldReveal.Begin(WorldRevealKind.Login); + _worldReveal.Begin(WorldRevealKind.Login, position.LandblockId); return new( true, diff --git a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs index a230e70d..cbe595a3 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockPresentationPipelineTests.cs @@ -1024,14 +1024,14 @@ public sealed class LandblockPresentationPipelineTests MaxGpuUploadBytes: 1_000_000, MaxGlRetireOperations: 1_000, DestinationReserveFraction: 0.75f)); - controller.PriorityLandblockId = priorityId; + controller.BeginDestinationReservation(1, priorityId, 0); Assert.Throws(() => controller.Tick(0x70, 0x70)); Assert.Empty(outbox); Assert.False(state.IsLoaded(laterId)); Assert.True(state.IsLoaded(unloadId)); - controller.PriorityLandblockId = 0; + controller.EndDestinationReservation(1); controller.Tick(0x70, 0x70); Assert.True(state.IsLoaded(priorityId)); diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index a4520daf..0caa5586 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -46,22 +46,23 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.OfferDestination( destination, teleportTimestampAdvanced: true); - Assert.Empty(harness.Streaming.PriorityWrites); + Assert.Empty(harness.Streaming.Reservations); harness.Controller.OnTeleportStarted(7); Assert.Equal(1, harness.Input.EndCount); Assert.Equal(1, harness.Mode.EnterPortalCount); Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection); - Assert.Equal((0x2021FFFFu, LocalPlayerTeleportController.NearRingRadius), - Assert.Single(harness.Streaming.PriorityWrites)); + Assert.Equal( + (1L, 0x20210001u, WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius), + Assert.Single(harness.Streaming.Reservations)); Assert.True(harness.Reveal.Snapshot.IsActive); Assert.Equal(WorldRevealKind.Portal, harness.Reveal.Snapshot.Kind); harness.Controller.OfferDestination( destination, teleportTimestampAdvanced: false); - Assert.Single(harness.Streaming.PriorityWrites); + Assert.Single(harness.Streaming.Reservations); } [Fact] @@ -89,7 +90,7 @@ public sealed class LocalPlayerTeleportControllerTests Assert.True(harness.Controller.IsActive); Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); Assert.NotNull(harness.Presentation.BeginProjection); - Assert.Single(harness.Streaming.PriorityWrites); + Assert.Single(harness.Streaming.Reservations); } [Fact] @@ -105,14 +106,14 @@ public sealed class LocalPlayerTeleportControllerTests teleportTimestampAdvanced: true); Assert.Equal(0u, harness.Controller.ActiveDestinationCell); - Assert.Empty(harness.Streaming.PriorityWrites); + Assert.Empty(harness.Streaming.Reservations); harness.Mode.Controller = new PlayerMovementController(new PhysicsEngine()); harness.Mode.Controller.SetPosition(Vector3.Zero, 0x20210001u, Vector3.Zero); harness.Controller.Tick(0.016f); Assert.Equal(0x20210001u, harness.Controller.ActiveDestinationCell); - Assert.Single(harness.Streaming.PriorityWrites); + Assert.Single(harness.Streaming.Reservations); } [Fact] @@ -158,7 +159,6 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Empty(harness.Streaming.Recenters); Assert.Equal(new Vector3(20f, 30f, 4f), harness.Placement.Position); Assert.Equal(0x20210123u, harness.Placement.CellId); - Assert.False(harness.Placement.Forced); } [Fact] @@ -188,7 +188,7 @@ public sealed class LocalPlayerTeleportControllerTests } [Fact] - public void ReadinessHold_UsesWallClockAndForcesExactlyAtTenSeconds() + public void ReadinessHold_NeverRevealsIncompleteWorldAndShowsWaitCueAfterFiveSeconds() { var harness = new Harness(worldReady: false); harness.Controller.OnTeleportStarted(5); @@ -197,15 +197,18 @@ public sealed class LocalPlayerTeleportControllerTests teleportTimestampAdvanced: true); harness.Presentation.EmitPlaceWhenReady = true; - harness.Controller.Tick(9.5f); + harness.Controller.Tick(4.9f); Assert.False(Assert.Single(harness.Presentation.WorldReadyValues)); + Assert.False(harness.Presentation.WaitCueValues[^1]); Assert.Equal(default, harness.Placement.Position); - harness.Controller.Tick(0.5f); + harness.Controller.Tick(0.1f); + harness.Controller.Tick(30f); - Assert.True(harness.Presentation.WorldReadyValues[^1]); - Assert.True(harness.Placement.Forced); - Assert.Equal(new Vector3(1f, 2f, 3f), harness.Placement.Position); + Assert.All(harness.Presentation.WorldReadyValues, Assert.False); + Assert.True(harness.Presentation.WaitCueValues[^1]); + Assert.Equal(default, harness.Placement.Position); + Assert.True(harness.Reveal.WaitCueShown); } [Fact] @@ -223,7 +226,6 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); Assert.True(Index(order, "placement") < Index(order, "[world-reveal] event=materialized")); - Assert.True(Index(order, "[world-reveal] event=materialized") < Index(order, "priority-clear")); Assert.Equal(1, harness.Reveal.PortalMaterializationCount); } @@ -249,6 +251,8 @@ public sealed class LocalPlayerTeleportControllerTests Assert.False(harness.Controller.IsActive); Assert.Equal(0u, harness.Controller.ActiveDestinationCell); Assert.Equal(1, harness.Session.LoginCompleteCount); + Assert.True(harness.Reveal.Snapshot.Completed); + Assert.False(harness.Reveal.Snapshot.Cancelled); } [Fact] @@ -261,7 +265,7 @@ public sealed class LocalPlayerTeleportControllerTests teleportTimestampAdvanced: true); harness.Controller.OnTeleportStarted(11); - harness.Controller.Tick(LocalPlayerTeleportController.MaximumWorldHoldSeconds); + harness.Controller.Tick(30f); Assert.Equal(default, harness.Placement.Position); Assert.Equal(0u, harness.Controller.ActiveDestinationCell); @@ -318,7 +322,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.OfferDestination( Position(0x20210001u, 20, 1f, 2f, 3f), teleportTimestampAdvanced: true); - harness.Streaming.PriorityClearCount = 0; + harness.Streaming.ReservationEnds.Clear(); harness.Placement.OnPlace = () => harness.Controller.OnTeleportStarted(21); harness.Presentation.Enqueue(TeleportAnimEvent.Place); @@ -327,7 +331,7 @@ public sealed class LocalPlayerTeleportControllerTests Assert.True(harness.Controller.IsActive); Assert.Equal(0u, harness.Controller.ActiveDestinationCell); Assert.Equal(0, harness.Reveal.PortalMaterializationCount); - Assert.Equal(1, harness.Streaming.PriorityClearCount); + Assert.Single(harness.Streaming.ReservationEnds); } [Fact] @@ -418,7 +422,7 @@ public sealed class LocalPlayerTeleportControllerTests var position = new Vector3(12f, 24f, 6f); Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f); - placement.Place(position, cell, rotation, forced: false); + placement.Place(position, cell, rotation); Assert.Equal(entity.Position, controllerSlot.Controller.Position); Assert.Equal(entity.ParentCellId, controllerSlot.Controller.CellId); @@ -560,7 +564,8 @@ public sealed class LocalPlayerTeleportControllerTests prepareCompositeTextures: (_, _) => { }, invalidateCompositeTextures: () => { }, isSpawnClaimUnhydratable: _ => false, - log: order.Add); + log: order.Add, + streaming: Streaming); Controller = new LocalPlayerTeleportController( Authority, Input, @@ -619,7 +624,9 @@ public sealed class LocalPlayerTeleportControllerTests } } - private sealed class FakeStreaming : ILocalPlayerTeleportStreamingOperations + private sealed class FakeStreaming + : ILocalPlayerTeleportStreamingOperations, + IWorldRevealStreamingScheduler { private readonly List _order; @@ -636,9 +643,10 @@ public sealed class LocalPlayerTeleportControllerTests public bool RecenterPending; public bool ResetResult = true; public bool LastResetWasSessionEnding; - public int PriorityClearCount; public readonly List<(int X, int Y, bool Sealed)> Recenters = new(); - public readonly List<(uint Landblock, int Radius)> PriorityWrites = new(); + public readonly List<(long Generation, uint Cell, int Radius)> + Reservations = new(); + public readonly List ReservationEnds = new(); public bool BeginRecenter(int x, int y, bool isSealedDungeon) { @@ -655,13 +663,17 @@ public sealed class LocalPlayerTeleportControllerTests public bool IsSealedDungeon(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u; - public void SetPriority(uint landblockId, int radius) => - PriorityWrites.Add((landblockId, radius)); + public void BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius) => + Reservations.Add( + (revealGeneration, destinationCell, requiredRenderRadius)); - public void ClearPriority() + public void EndDestinationReservation(long revealGeneration) { - PriorityClearCount++; - _order.Add("priority-clear"); + ReservationEnds.Add(revealGeneration); + _order.Add("reservation-end"); } } @@ -674,16 +686,14 @@ public sealed class LocalPlayerTeleportControllerTests public Vector3 Position; public uint CellId; public Quaternion Rotation; - public bool Forced; public Action? OnPlace; - public void Place(Vector3 position, uint cellId, Quaternion rotation, bool forced) + public void Place(Vector3 position, uint cellId, Quaternion rotation) { _order.Add("placement"); Position = position; CellId = cellId; Rotation = rotation; - Forced = forced; OnPlace?.Invoke(); } } @@ -731,6 +741,7 @@ public sealed class LocalPlayerTeleportControllerTests public Matrix4x4? BeginProjection; public bool EmitPlaceWhenReady; public readonly List WorldReadyValues = new(); + public readonly List WaitCueValues = new(); public int DisposeFailuresRemaining; public int DisposeCount; @@ -760,6 +771,7 @@ public sealed class LocalPlayerTeleportControllerTests public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick"); public void EnterTunnel() => IsPortalViewportVisible = true; public void ExitTunnel() => IsPortalViewportVisible = false; + public void SetWaitCue(bool visible) => WaitCueValues.Add(visible); public void Reset() { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs index 7063d2d7..4def42a4 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingCompletionQueueTests.cs @@ -200,6 +200,7 @@ public sealed class StreamingCompletionQueueTests result, estimate, priority, + priority == StreamingCompletionPriority.Destination ? 1L : 0L, result.Generation, sequence, Stopwatch.GetTimestamp()); diff --git a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs index 2b0ddc74..4666719e 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingWorkBudgetTests.cs @@ -117,6 +117,110 @@ public sealed class StreamingWorkBudgetTests meter.Snapshot.LastLimit); } + [Fact] + public void ActiveReveal_ProtectsDestinationShareAcrossEveryCountAndByteDimension() + { + var meter = new StreamingWorkMeter( + Budget( + milliseconds: 100, + completions: 4, + cpuBytes: 100, + entityOperations: 8, + gpuBytes: 100, + retireOperations: 4), + static () => 0, + timestampFrequency: 1_000, + destinationReservationActive: true); + + StreamingWorkCost unreservedShare = new( + CompletionAdmissions: 1, + AdoptedCpuBytes: 25, + EntityOperations: 2, + GpuUploadBytes: 25, + GlRetireOperations: 1); + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve(unreservedShare, "ordinary")); + meter.Complete(); + Assert.Equal( + StreamingWorkAdmission.Yielded, + meter.TryReserve( + new StreamingWorkCost(CompletionAdmissions: 1), + "ordinary-over-reserve")); + + using (meter.EnterLane(StreamingWorkLane.Destination)) + { + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve( + new StreamingWorkCost( + CompletionAdmissions: 3, + AdoptedCpuBytes: 75, + EntityOperations: 6, + GpuUploadBytes: 75, + GlRetireOperations: 3), + "destination")); + meter.Complete(); + } + + StreamingWorkMeterSnapshot snapshot = meter.Snapshot; + Assert.Equal(unreservedShare, snapshot.NonDestinationUsed); + Assert.Equal(3, snapshot.DestinationUsed.CompletionAdmissions); + Assert.Equal(100, snapshot.Used.AdoptedCpuBytes); + Assert.Equal(100, snapshot.Used.GpuUploadBytes); + } + + [Fact] + public void InactiveReveal_DoesNotApplyDestinationReservation() + { + var meter = new StreamingWorkMeter( + Budget(completions: 4), + static () => 0, + timestampFrequency: 1_000, + destinationReservationActive: false); + + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve( + new StreamingWorkCost(CompletionAdmissions: 4), + "ordinary")); + meter.Complete(); + + Assert.Equal(4, meter.Snapshot.NonDestinationUsed.CompletionAdmissions); + Assert.Equal(0, meter.Snapshot.DestinationUsed.CompletionAdmissions); + } + + [Fact] + public void ActiveReveal_ProtectsDestinationWallClockAfterOrdinaryShareIsSpent() + { + long now = 0; + var meter = new StreamingWorkMeter( + Budget(milliseconds: 100), + () => now, + timestampFrequency: 1_000, + destinationReservationActive: true); + + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve(default, "ordinary")); + now = 25; + meter.Complete(); + Assert.Equal( + StreamingWorkAdmission.Yielded, + meter.TryReserve(default, "ordinary-after-25ms")); + + using (meter.EnterLane(StreamingWorkLane.Destination)) + { + Assert.Equal( + StreamingWorkAdmission.Admitted, + meter.TryReserve(default, "destination")); + now = 99; + meter.Complete(); + } + + Assert.Equal(2, meter.Snapshot.CompletedOperations); + } + [Fact] public void MeterUsesInjectedMonotonicClockAndRecordsOverrunAndFailure() { @@ -427,21 +531,32 @@ public sealed class StreamingWorkBudgetTests cpuBytes: 1_000, gpuBytes: 44, retireOperations: 1)); - controller.PriorityLandblockId = center; + controller.BeginDestinationReservation(1, center, 0); controller.Tick(32, 32); Assert.Equal([center], applied); Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions); - Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount); + Assert.Equal(2, controller.WorkDiagnostics.LastFrame.YieldCount); Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); controller.Tick(32, 32); Assert.Equal([center, ordinary], applied); - Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations); + + for (int i = 0; + i < 4 + && (controller.WorkDiagnostics.DeferredCompletions != 0 + || source.BacklogCount != 0); + i++) + { + controller.Tick(32, 32); + } + + Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions); + Assert.Equal(0, source.BacklogCount); } [Fact] diff --git a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs index 53335367..0c719067 100644 --- a/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs +++ b/tests/AcDream.App.Tests/Streaming/WorldRevealCoordinatorTests.cs @@ -13,7 +13,9 @@ public sealed class WorldRevealCoordinatorTests public int InvalidateCount { get; private set; } public int PrepareCount { get; private set; } - public WorldRevealCoordinator Build(List? logs = null) => new( + public WorldRevealCoordinator Build( + List? logs = null, + IWorldRevealStreamingScheduler? streaming = null) => new( isRenderNeighborhoodReady: (_, _) => RenderReady, isSpawnCellReady: _ => CollisionReady, isTerrainNeighborhoodReady: (_, _) => CollisionReady, @@ -25,7 +27,8 @@ public sealed class WorldRevealCoordinatorTests CompositesReady = false; }, isSpawnClaimUnhydratable: _ => false, - log: logs is null ? null : new Action(logs.Add)); + log: logs is null ? null : new Action(logs.Add), + streaming: streaming); } [Fact] @@ -34,7 +37,7 @@ public sealed class WorldRevealCoordinatorTests var state = new State { CompositesReady = true }; WorldRevealCoordinator coordinator = state.Build(); - long generation = coordinator.Begin(WorldRevealKind.Login); + long generation = coordinator.Begin(WorldRevealKind.Login, 0x11340021u); Assert.Equal(1, generation); Assert.Equal(1, state.InvalidateCount); @@ -52,7 +55,7 @@ public sealed class WorldRevealCoordinatorTests CollisionReady = true, }; WorldRevealCoordinator coordinator = state.Build(); - coordinator.Begin(WorldRevealKind.Login); + coordinator.Begin(WorldRevealKind.Login, outdoorCell); WorldRevealReadinessSnapshot first = coordinator.PrepareAndEvaluate(outdoorCell); state.CompositesReady = true; @@ -76,7 +79,7 @@ public sealed class WorldRevealCoordinatorTests CompositesReady = true, }; WorldRevealCoordinator coordinator = state.Build(logs); - coordinator.Begin(WorldRevealKind.Portal); + coordinator.Begin(WorldRevealKind.Portal, outdoorCell); state.CompositesReady = true; Assert.True(coordinator.Evaluate(outdoorCell).IsReady); @@ -103,7 +106,7 @@ public sealed class WorldRevealCoordinatorTests CompositesReady = true, }; WorldRevealCoordinator coordinator = state.Build(); - coordinator.Begin(WorldRevealKind.Portal); + coordinator.Begin(WorldRevealKind.Portal, 0x11340021u); coordinator.Cancel(); coordinator.PrepareAndEvaluate(0x11340021u); @@ -137,8 +140,8 @@ public sealed class WorldRevealCoordinatorTests isSpawnClaimUnhydratable: _ => false, quiescence: quiescence); - Assert.Equal(1, coordinator.Begin(WorldRevealKind.Login)); - Assert.Equal(2, coordinator.Begin(WorldRevealKind.Portal)); + Assert.Equal(1, coordinator.Begin(WorldRevealKind.Login, 0x11340021u)); + Assert.Equal(2, coordinator.Begin(WorldRevealKind.Portal, 0x11340021u)); Assert.False(availability.IsWorldAvailable); Assert.Equal(2, availability.QuiescedGeneration); Assert.Equal(1, audio.SuspendCalls); @@ -149,6 +152,30 @@ public sealed class WorldRevealCoordinatorTests Assert.Equal(1, audio.ResumeCalls); } + [Fact] + public void RevealGeneration_OwnsExactDestinationReservationUntilCompletion() + { + var state = new State(); + var scheduler = new RecordingDestinationScheduler(); + WorldRevealCoordinator coordinator = state.Build(streaming: scheduler); + + long first = coordinator.Begin( + WorldRevealKind.Portal, + 0x11340021u); + long second = coordinator.Begin( + WorldRevealKind.Portal, + 0x20210123u); + + Assert.Equal( + [ + (first, 0x11340021u, 1), + (second, 0x20210123u, 0), + ], + scheduler.Begins); + coordinator.Complete(); + Assert.Equal([second], scheduler.Ends); + } + private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence { public int SuspendCalls { get; private set; } @@ -157,4 +184,21 @@ public sealed class WorldRevealCoordinatorTests public void SuspendWorldAudio() => SuspendCalls++; public void ResumeWorldAudio() => ResumeCalls++; } + + private sealed class RecordingDestinationScheduler + : IWorldRevealStreamingScheduler + { + public List<(long Generation, uint Cell, int Radius)> Begins { get; } = []; + public List Ends { get; } = []; + + public void BeginDestinationReservation( + long revealGeneration, + uint destinationCell, + int requiredRenderRadius) => + Begins.Add( + (revealGeneration, destinationCell, requiredRenderRadius)); + + public void EndDestinationReservation(long revealGeneration) => + Ends.Add(revealGeneration); + } } diff --git a/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs b/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs new file mode 100644 index 00000000..25aa6193 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/PortalWaitNoticeControllerTests.cs @@ -0,0 +1,37 @@ +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class PortalWaitNoticeControllerTests +{ + [Fact] + public void Notice_IsCenteredOverlayAndDisposesFromRetainedRoot() + { + var root = new UiRoot + { + Width = 1280f, + Height = 720f, + }; + + using (var controller = new PortalWaitNoticeController(root)) + { + UiText text = Assert.IsType(Assert.Single(root.Children)); + Assert.False(text.Visible); + Assert.True(text.Centered); + Assert.True(text.ClickThrough); + Assert.Equal(root.Width, text.Width); + Assert.Equal(root.Height, text.Height); + + controller.Set("In Portal Space - Please Wait..."); + + Assert.True(text.Visible); + UiText.Line line = Assert.Single(text.LinesProvider!()); + Assert.Equal("In Portal Space - Please Wait...", line.Text); + + controller.Set(null); + Assert.False(text.Visible); + } + + Assert.Empty(root.Children); + } +} diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs index 5c735bf8..d9bdd3e4 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs @@ -18,7 +18,7 @@ public class StreamingControllerPriorityApplyTests generation); [Fact] - public void PriorityLandblock_WinsExecutionOrderAfterBoundedAdmission() + public void DestinationReservation_WinsExecutionOrderAfterBoundedAdmission() { uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); var outbox = new Queue(new LandblockStreamResult[] @@ -48,7 +48,7 @@ public class StreamingControllerPriorityApplyTests workBudgetOptions: GenerousBudget()) { MaxCompletionsPerFrame = 4 }; - ctrl.PriorityLandblockId = priority; + ctrl.BeginDestinationReservation(1, priority, 0); ctrl.Tick(169, 180); Assert.NotEmpty(applied); @@ -87,10 +87,10 @@ public class StreamingControllerPriorityApplyTests nearRadius: 4, farRadius: 12) { MaxCompletionsPerFrame = 4 }; - ctrl.PriorityLandblockId = priority; + ctrl.BeginDestinationReservation(1, priority, 0); ctrl.Tick(169, 180); // priority + some others - ctrl.PriorityLandblockId = 0u; + ctrl.EndDestinationReservation(1); ctrl.Tick(169, 180); // drains the deferred remainder ctrl.Tick(169, 180); @@ -101,7 +101,7 @@ public class StreamingControllerPriorityApplyTests [Fact] public void PriorityNeverArrives_noThrow_noLoss_noDoubleApply() { - // While a PriorityLandblockId is set but the matching completion never + // While a reveal reservation is active but its matching completion never // arrives (e.g. the load failed), ordinary results still enter the typed // Near/Far queues and advance under the same budget without loss. uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); @@ -134,7 +134,7 @@ public class StreamingControllerPriorityApplyTests { MaxCompletionsPerFrame = 4 }; // Set a priority id that will NEVER appear in the outbox. - ctrl.PriorityLandblockId = priority; + ctrl.BeginDestinationReservation(1, priority, 0); // Tick several times — should not throw and every non-priority completion // must still be applied without duplication. @@ -160,6 +160,30 @@ public class StreamingControllerPriorityApplyTests MaxGlRetireOperations: 10_000, DestinationReserveFraction: 0.75f); + [Fact] + public void DestinationReservation_StaleGenerationCannotClearReplacement() + { + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: _ => Array.Empty(), + applyTerrain: (_, _) => { }, + state: new GpuWorldState(), + nearRadius: 1, + farRadius: 2); + + controller.BeginDestinationReservation(1, 0x11340021u, 1); + controller.BeginDestinationReservation(2, 0x20210123u, 0); + controller.EndDestinationReservation(1); + + Assert.Equal(2, controller.ActiveRevealGeneration); + Assert.Equal(0x2021FFFFu, controller.DestinationLandblockId); + Assert.Equal(0, controller.DestinationRadius); + + controller.EndDestinationReservation(2); + Assert.Equal(0, controller.ActiveRevealGeneration); + } + [Fact] public void DeferredCompaction_ApplyFailureRetainsExactResultWithoutReplayingCommittedPrefix() { @@ -199,8 +223,8 @@ public class StreamingControllerPriorityApplyTests farRadius: 12) { MaxCompletionsPerFrame = 3, - PriorityLandblockId = priority, }; + ctrl.BeginDestinationReservation(1, priority, 0); Assert.Throws(() => ctrl.Tick(169, 180)); Assert.Equal([first], applied); @@ -215,7 +239,6 @@ public class StreamingControllerPriorityApplyTests [Fact] public void ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow() { - uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); var outbox = new Queue( Enumerable.Range(0, 6) .Select(i => (LandblockStreamResult)LoadedOf( @@ -243,8 +266,7 @@ public class StreamingControllerPriorityApplyTests MaxGpuUploadBytes: 1_000_000, MaxGlRetireOperations: 1_000, DestinationReserveFraction: 0.75f)) - { MaxCompletionsPerFrame = 4, PriorityLandblockId = priority }; - + { MaxCompletionsPerFrame = 4 }; ctrl.Tick(169, 180); Assert.Equal(4, applied.Count); applied.Clear(); @@ -452,7 +474,8 @@ public class StreamingControllerPriorityApplyTests state: state, nearRadius: 0, farRadius: 0, - onLandblockLoaded: loadedCallbacks.Add); + onLandblockLoaded: loadedCallbacks.Add, + workBudgetOptions: GenerousBudget()); ctrl.Tick(10, 10);