perf(streaming): quiesce retired generations and budget teardown

Publish the retail blocking-for-cells edge before deferred recenter work, freeze old-world presentation/simulation/audio, and advance full-window retirement from exact metered entity and owner cursors. This removes synchronous portal teardown without allowing retained owners to remain observable.
This commit is contained in:
Erik 2026-07-24 18:29:52 +02:00
parent b8f6317fe1
commit bb16f74fd4
38 changed files with 1691 additions and 170 deletions

View file

@ -118,7 +118,8 @@ disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal formula-media/foreground correction passes
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
**M4 feature work order (paused while the authorized performance program
executes):** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
@ -130,17 +131,23 @@ user-accepted. Favorite-spell press-time selection and right-click local
examination are implemented; the current visual gate is its corrected modern
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
**Active performance side track:** Slices AC of
`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
**Current active engineering work:** complete Slices DL of
`docs/plans/2026-07-24-modern-runtime-architecture.md` under the recorded
approval interlocks. Slices AD completed 2026-07-24.
Production world-mesh streaming now consumes the validated machine-local
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
lifetime, full-suite, and user-visual gates passed. Process allocation fell
41.861.9%, GC pause time fell 48.454.1%, uncapped CPU/GPU p99 improved
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D (typed
handles and unified residency accounting) is next; ECS/MP3 remains behind its
explicit user decision gate. Evidence:
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
typed residency policy/accounting and bounded retained caches. Slice E0E3 now
enforce typed completion admission, generation-scoped old-world quiescence,
and metered full-window/recenter retirement; E4 cursor-budgeted publication is
next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G
remain behind the explicit ECS decision gate and Slice J behind its separate
gameplay-owner gate. Evidence:
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and
`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@ -161,7 +168,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire
truth remain correct.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice D+, and #225's
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's
lifestone/particle alpha visual gate.
Start structural work at `memory/project_gamewindow_decomposition.md` and

View file

@ -116,7 +116,8 @@ disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the appraisal formula-media/foreground correction passes
3,557 App tests / 3 skips and 7,988 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
**M4 feature work order (paused while the authorized performance program
executes):** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
@ -128,17 +129,23 @@ user-accepted. Favorite-spell press-time selection and right-click local
examination are implemented; the current visual gate is its corrected modern
scarab/prismatic formula, DAT component icons, and authored 310 x 400 extent.
**Active performance side track:** Slices AC of
`docs/plans/2026-07-24-modern-runtime-architecture.md` completed 2026-07-24.
**Current active engineering work:** complete Slices DL of
`docs/plans/2026-07-24-modern-runtime-architecture.md` under the recorded
approval interlocks. Slices AD completed 2026-07-24.
Production world-mesh streaming now consumes the validated machine-local
`acdream.pak` through `IPreparedAssetSource`; live DAT extraction remains
explicit bake/equivalence/UI-Studio tooling. Capped, uncapped, dense-Arwic,
lifetime, full-suite, and user-visual gates passed. Process allocation fell
41.861.9%, GC pause time fell 48.454.1%, uncapped CPU/GPU p99 improved
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D (typed
handles and unified residency accounting) is next; ECS/MP3 remains behind its
explicit user decision gate. Evidence:
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
17.0%/20.6%, and invalid Setup-probe exceptions fell to zero. Slice D unified
typed residency policy/accounting and bounded retained caches. Slice E0E3 now
enforce typed completion admission, generation-scoped old-world quiescence,
and metered full-window/recenter retirement; E4 cursor-budgeted publication is
next. Physical GPU/FPS gates are paused while the desktop is RDP. Slices F/G
remain behind the explicit ECS decision gate and Slice J behind its separate
gameplay-owner gate. Evidence:
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and
`docs/research/2026-07-24-slice-d-unified-residency-report.md`.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
@ -159,7 +166,7 @@ uncapped Release presentation is smooth, while physics, collision, and wire
truth remain correct.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice D+, and #225's
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E4+, and #225's
lifestone/particle alpha visual gate.
Start structural work at `memory/project_gamewindow_decomposition.md` and

View file

@ -44,9 +44,11 @@ What does NOT go here:
smooth and physics, collision, and wire state remain correct.
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
production compilation and all tests pass.
- **Performance side track:** prepared-content Slices AC and unified-residency
Slice D are complete. Slice E cost-budgeted streaming/retirement is next.
Evidence:
- **Performance side track:** Slices AD and Slice E0E3 are complete. Typed
completion admission, immediate generation-scoped old-world quiescence, and
budgeted full-window/recenter retirement are live; E4 cursor-budgeted
publication is next. Physical performance/visual evidence is paused while
the desktop is RDP. Evidence:
[`research/2026-07-24-slice-d-unified-residency-report.md`](research/2026-07-24-slice-d-unified-residency-report.md).
The [documentation map](README.md) defines how this tactical ledger relates to

View file

@ -8,16 +8,20 @@ banner cannot silently override the current program state.
- **Milestone state:** M3, “Cast a spell,” landed 2026-07-21. M4, “Live in the
world,” is active.
- **Active gameplay program:** the pre-M4 world-interaction completion program
- **M4 gameplay program (paused while the authorized performance program
executes):** the pre-M4 world-interaction completion program
in
[`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md).
The eight-slice `GameWindow` decomposition and its connected visual matrix
are complete.
- **Active performance side track:** Slices AC of
- **Current active engineering program:** Slices AD and Slice E0E3 of
[`plans/2026-07-24-modern-runtime-architecture.md`](plans/2026-07-24-modern-runtime-architecture.md)
are complete. Production world-mesh streaming consumes the validated
prepared package; Slice D unified residency/accounting is next. See the
[Slice C physical cutover report](research/2026-07-24-slice-c-prepared-asset-cutover-report.md).
prepared package, typed residency is bounded/accounted, and streaming now
has typed admission plus immediate old-generation quiescence and metered
retirement cursors. E4 cursor-budgeted publication is next. Physical
GPU/FPS gates wait for the physical console because the desktop is RDP. See
the [Slice D residency report](research/2026-07-24-slice-d-unified-residency-report.md).
- **Completed gates:** R6 locomotion/collision/projectile/teleport/radar and
the final two-client portal-out/materialization observer comparison (`#218`).
- **Separate visual verification:** the shared-alpha lifestone/particle result
@ -28,7 +32,7 @@ banner cannot silently override the current program state.
user decision gate; Linux/headless automation (Track LH) remains future work.
- **Divergence audit:** 178 active rows — IA 17, AD 37, AP 85, TS 34,
and UN 5 — plus the retained retired TS-37 history note.
- **Latest automated baseline:** Release build succeeds; 8,076 tests pass and
- **Latest automated baseline:** Release build succeeds; 8,148 tests pass and
five are intentionally skipped. The capped, uncapped, and dense-Arwic
physical prepared-asset routes all completed their portal/input/lifetime
assertions and disconnected gracefully.

View file

@ -720,8 +720,19 @@ exact reference identity and retry position, rejects stale generations, and
executes every class through the same meter. Priority changes order, never the
budget. Lifecycle artifacts publish worker and per-class backlog, retained
bytes/age, yields, oversizes, overruns, and pending publication/retirement
facts. Publication remains transaction-atomic until Slice E4; retirement
quiesce/cursors belong to E3.
facts.
At a hard login/portal reveal edge, `WorldGenerationQuiescence` makes the old
generation unavailable through the same `WorldRevealCoordinator` lifetime.
World rendering and spatial queries return no old content; object/effect clocks,
liveness, and spatial reconciliation stop; world-space audio is silenced; UI,
network/event/command delivery, portal presentation, destination streaming, and
readiness continue. Canonical live records remain retained until physical
teardown converges, so quiescence neither reconstructs server identity nor
invents Hidden/UnHide transitions. Full-window and shared-origin retirement
advance only from `StreamingController.Tick` through stable resident and
per-owner entity/stage cursors on the frame's one meter. Publication remains
transaction-atomic until Slice E4.
### World-reveal readiness ownership

View file

@ -68,7 +68,7 @@ accepted-divergence entries (#96, #49, #50).
|---|---|---|---|---|---|
| AD-38 | Outgoing teleport viewports retire when retail's quantized animation level exceeds the last captured visible level 1022 (index 96), suppressing levels 1023/1024 up to 20.2 ms before retail's literal `elapsed >= 1.0` state edge. Incoming fades retain the exact timer. | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`OutgoingViewportReachedTerminalProjection`) | An uncapped 2000 FPS pass can publish the finite tunnel at levels 1023/1024 even though the paired 2013 retail capture switches viewports after 1022. The table-level cutover preserves the captured visible viewport ordering without throttling the application. | Exit sound, viewport replacement, and logout tunnel entry can occur at most two easing-table quanta (about 20.2 ms) earlier than retail's logical timer. | `UIGlobals::GetAnimLevel @ 0x004EE540`; `gmSmartBoxUI::UseTime @ 0x004D6E30`; paired retail/acdream captures documented in `docs/research/2026-07-15-retail-portal-space-pseudocode.md` |
| AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal an empty collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, or missing scenery; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) |
| AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] |
| AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) |

View file

@ -1605,13 +1605,16 @@ port in any phase — no separate listing here.
> and dense-Arwic physical routes passed; third-visit cache residence was
> non-growing and no checkpoint retained staged or retiring bytes. [Residency
> report](../research/2026-07-24-slice-d-unified-residency-report.md). Slice E
> cost-budgeted streaming/retirement is active. E2 has replaced the flat
> cost-budgeted streaming/retirement is active. E2 replaced the flat
> deferred list, count-only throttle, priority hunt, and unload bypass with one
> typed-meter scheduler over explicit stable destination/control/unload/Near/
> Far queues; production prices worker results before adoption and lifecycle
> artifacts expose exact backlog/retained-byte facts. E3 next adds immediate
> old-generation quiesce and cursored retirement. F/G and J retain their
> explicit approval gates.
> artifacts expose exact backlog/retained-byte facts. E3 now makes the old
> generation unavailable at the hard reveal edge and freezes its world
> simulation/audio while full-window and shared-origin teardown advance through
> metered resident, entity, and owner-stage cursors with exact retry receipts.
> E4 cursor-budgets publication next. F/G and J retain their explicit approval
> gates.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the
umbrella design — read it first). **Goal:** smoothness first (no frame over

View file

@ -93,10 +93,12 @@ The separately authorized modern-runtime performance program has completed
Slices AD: 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 E0E2 now define and enforce typed completion admission through explicit
stable priority queues; E3 adds immediate old-generation quiesce and cursored
retirement next. Slices F/G remain behind their explicit ECS approval gate,
and Slice J remains behind its gameplay-owner approval gate.
Slice E0E3 now define and enforce typed completion admission through explicit
stable priority queues, publish an immediate generation-scoped old-world
quiesce at login/portal boundaries, and advance full-window/recenter teardown
through exact metered retirement cursors. E4 next makes publication itself
cursor-budgeted. Slices F/G remain behind their explicit ECS approval gate, and
Slice J remains behind its gameplay-owner approval gate.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2's
status hand selected-object availability and existing

View file

@ -2,7 +2,7 @@
**Date:** 2026-07-24
**Status:** Slices AD complete. Slice E is active (E0/E1 complete).
**Status:** Slices AD complete. Slice E is active (E0E3 complete).
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice E — Cost-budgeted streaming and retirement
**Status:** active — E0/E1/E2 complete; E3 quiesce and budgeted retirement next
**Status:** active — E0/E1/E2/E3 complete; E4 cursor-budgeted publication next
**Program:** `docs/plans/2026-07-24-modern-runtime-architecture.md`
**Baseline:** Slice D closeout commit `66690805`
**Behavior contract:** no visual-quality or retail-behavior reduction
@ -288,6 +288,35 @@ E6; the active desktop was RDP and is not valid performance evidence.
- Prove no old generation ticks, emits, collides, targets, or renders while its
retained memory converges.
**Complete 2026-07-24.** `WorldGenerationQuiescence` now publishes the hard
login/portal edge through one generation-scoped availability state owned by
`WorldRevealCoordinator`. The edge withdraws world drawing, picking/radar
spatial queries, target selection, object/static animation, PhysicsScript time,
particle/effect progression, liveness, spatial reconciliation, and owner-tagged
3-D audio while network/event/command, portal presentation, UI, streaming, and
destination readiness continue. A superseding reveal remains continuously
quiescent and only its exact generation can reopen the world; canonical live
records and readiness state remain retained, so the mechanism does not
synthesize Hidden/UnHide or rebuild server identity.
Full-window and shared-origin retirement are now retained frame transactions.
Generation invalidation, worker-inbox clearing, accepted-payload release,
region clearing, stable resident capture, and landblock detachment all resume
from exact cursors through the frame's single `StreamingWorkMeter`.
`LandblockRetirementCoordinator` preserves stable FIFO order and advances one
entity or one owner stage per reservation across scripts, classifications,
lighting, translucency, plugin projection, terrain, physics, cell visibility,
building registries, and environment cells. Failures retry the exact unfinished
entity/stage without replaying committed work. A landblock detach remains one
named indivisible spatial boundary; its complete entity count is charged before
the operation.
Validation: 51 focused reveal/quiescence/retirement tests passed; the complete
App suite passed 3,666 / 3 skipped; the complete solution passed 8,148 / 5
skipped; Release solution build green. The physical connected performance and
visual gate remains deferred to E6 because the active desktop is RDP and is not
valid GPU/FPS evidence.
### E4 — Cursor-budgeted publication
- Move stable ordered arrays/cost facts into retained receipts.

View file

@ -122,7 +122,14 @@ public sealed class AudioHookSink : IAnimationHookSink
if (waveId == 0) return;
WaveData? wave = _cache.GetWave(waveId);
if (wave is null) return;
_engine.Play3DWave(waveId, wave, worldPos, volume, priority, pitch);
_engine.Play3DWave(
entityId,
waveId,
wave,
worldPos,
volume,
priority,
pitch);
}
}

View file

@ -54,7 +54,13 @@ namespace AcDream.App.Audio;
/// unaffected.
/// </para>
/// </summary>
public sealed unsafe class OpenAlAudioEngine : IAudioEngine
internal interface IWorldAudioQuiescence
{
void SuspendWorldAudio();
void ResumeWorldAudio();
}
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
{
// ── Backends ─────────────────────────────────────────────────────────────
private AL? _al;
@ -71,12 +77,14 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
private sealed class Slot3D
{
public uint SourceId;
public uint OwnerId;
public float PlayingGain; // gain at play time (for eviction compare)
public bool InUse;
public uint PriorityBase; // raw priority from SoundEntry.Priority
}
private readonly Slot3D[] _pool3D = new Slot3D[PoolSize3D];
private int _pool3DCursor; // round-robin start
private bool _worldAudioSuspended;
private readonly uint[] _poolUi = new uint[PoolSizeUi];
@ -238,6 +246,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
/// Returns true on success, false if the buffer was rejected.
/// </summary>
public bool Play3DWave(
uint ownerId,
uint waveId,
WaveData wave,
Vector3 position,
@ -245,7 +254,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
float priority,
float pitch = 1.0f)
{
if (!_available || _al is null) return false;
if (_worldAudioSuspended || !_available || _al is null) return false;
float effectiveGain = volume * SfxVolume;
if (effectiveGain < 0.001f) return false; // silent; skip
@ -284,11 +293,39 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
slot.PlayingGain = effectiveGain;
slot.InUse = true;
slot.OwnerId = ownerId;
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
return true;
}
/// <summary>
/// Stops every world-space voice while preserving the independent UI
/// source pool. Retail suppresses ambient/object audio while cell loading
/// blocks world maintenance; stopped voices are not resumed afterward.
/// </summary>
public void SuspendWorldAudio()
{
_worldAudioSuspended = true;
for (int i = 0; i < _pool3D.Length; i++)
StopWorldSlot(_pool3D[i]);
}
public void ResumeWorldAudio() => _worldAudioSuspended = false;
internal void StopAllForOwner(uint ownerId)
{
if (ownerId == 0)
return;
for (int i = 0; i < _pool3D.Length; i++)
{
Slot3D slot = _pool3D[i];
if (slot.InUse && slot.OwnerId == ownerId)
StopWorldSlot(slot);
}
}
/// <summary>
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
/// listener position).
@ -469,4 +506,18 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine
_al.GetSourceProperty(sourceId, GetSourceInteger.SourceState, out int state);
return state == (int)SourceState.Playing;
}
private void StopWorldSlot(Slot3D slot)
{
if (_available && _al is not null && slot.SourceId != 0)
{
_al.SourceStop(slot.SourceId);
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0);
}
slot.OwnerId = 0;
slot.PlayingGain = 0f;
slot.PriorityBase = 0;
slot.InUse = false;
}
}

View file

@ -384,7 +384,8 @@ internal sealed class FrameRootCompositionPhase
retailPViewCells,
worldScenePasses,
d.RenderRange,
worldSceneDiagnostics);
worldSceneDiagnostics,
live.WorldAvailability);
Fault(FrameRootCompositionPoint.WorldRendererCreated);
bindings = new FrameRootRuntimeBindings();
@ -469,7 +470,8 @@ internal sealed class FrameRootCompositionPhase
live.WorldState,
session.LiveSession,
session.LocalPlayerFrame,
session.LiveSpatialReconciler);
session.LiveSpatialReconciler,
live.WorldAvailability);
var cameraFrame = new CameraFrameController(
host.CameraController,
d.InputCapture,
@ -496,7 +498,8 @@ internal sealed class FrameRootCompositionPhase
new StopwatchClientMonotonicTimeSource()),
session.LocalTeleport,
new PlayerModeAutoEntryFramePhase(session.PlayerModeAutoEntry),
cameraFrame);
cameraFrame,
live.WorldAvailability);
Fault(FrameRootCompositionPoint.UpdateRootCreated);
var bindingsLease = scope.Own(

View file

@ -78,6 +78,7 @@ internal sealed record LivePresentationResult(
EntitySpawnAdapter EntitySpawnAdapter,
EntityScriptActivator EntityScriptActivator,
RetailStaticAnimatingObjectScheduler StaticAnimationScheduler,
WorldGenerationAvailabilityState WorldAvailability,
GpuWorldState WorldState,
LiveEntityRuntime LiveEntities,
ProjectileController ProjectileController,
@ -319,10 +320,12 @@ internal sealed class LivePresentationCompositionPhase
staticAnimationScheduler.Unregister,
(entity, info) => staticAnimationScheduler.Rebind(entity, info));
var worldAvailability = new WorldGenerationAvailabilityState();
var worldState = new GpuWorldState(
wbSpawnAdapter,
d.ClassificationCache.InvalidateLandblock,
entityScriptActivator);
entityScriptActivator,
worldAvailability);
bindings = new LivePresentationRuntimeBindings();
if (d.DevWorldEntities is { } devWorldEntities)
{
@ -524,6 +527,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldAvailability,
worldState,
liveEntities,
projectileController,
@ -568,6 +572,7 @@ internal sealed class LivePresentationCompositionPhase
EntitySpawnAdapter entitySpawnAdapter,
EntityScriptActivator entityScriptActivator,
RetailStaticAnimatingObjectScheduler staticAnimationScheduler,
WorldGenerationAvailabilityState worldAvailability,
GpuWorldState worldState,
LiveEntityRuntime liveEntities,
ProjectileController projectileController,
@ -942,6 +947,7 @@ internal sealed class LivePresentationCompositionPhase
entitySpawnAdapter,
entityScriptActivator,
staticAnimationScheduler,
worldAvailability,
worldState,
liveEntities,
projectileController,

View file

@ -321,6 +321,11 @@ internal sealed class SessionPlayerCompositionPhase
var spawnClaimClassifier = new DatSpawnClaimHydrationClassifier(
content.Dats,
d.DatLock);
var worldQuiescence = new WorldGenerationQuiescence(
live.WorldAvailability,
d.Selection,
live.WorldState,
content.Audio?.Engine);
var worldReveal = new WorldRevealCoordinator(
streaming.IsRenderNeighborhoodResident,
d.PhysicsEngine.IsSpawnCellReady,
@ -333,7 +338,8 @@ internal sealed class SessionPlayerCompositionPhase
radius),
live.DrawDispatcher.InvalidateCompositeWarmupReadiness,
spawnClaimClassifier.IsUnhydratable,
d.Log);
d.Log,
worldQuiescence);
Fault(SessionPlayerCompositionPoint.WorldRevealCreated);
return CompleteSessionPlayer(

View file

@ -1,5 +1,6 @@
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
@ -59,6 +60,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
private readonly IWorldScenePassExecutor _passes;
private readonly IWorldRenderRangeSource _renderRange;
private readonly IWorldSceneDiagnostics _diagnostics;
private readonly IWorldGenerationAvailability _availability;
public WorldSceneRenderer(
IRenderFrameFoundationSource foundation,
@ -73,7 +75,8 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
IRetailPViewCellSource pviewCells,
IWorldScenePassExecutor passes,
IWorldRenderRangeSource renderRange,
IWorldSceneDiagnostics diagnostics)
IWorldSceneDiagnostics diagnostics,
IWorldGenerationAvailability? availability = null)
{
_foundation = foundation ?? throw new ArgumentNullException(nameof(foundation));
_login = login ?? throw new ArgumentNullException(nameof(login));
@ -89,6 +92,7 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
_renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public WorldRenderFrameOutcome Render(RenderFrameInput input)
@ -100,6 +104,13 @@ internal sealed class WorldSceneRenderer : IWorldSceneFramePhase
try
{
_selection?.BeginFrame();
if (!_availability.IsWorldAvailable)
{
_selection?.CompleteFrame();
selectionFrameStarted = false;
return default;
}
RenderFrameFoundation foundation = _foundation.Foundation;
if (foundation.PortalViewportVisible)
{

View file

@ -56,6 +56,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
private readonly IWorldGenerationAvailability _availability;
/// <summary>
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
@ -72,11 +73,13 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null,
EntityScriptActivator? entityScriptActivator = null)
EntityScriptActivator? entityScriptActivator = null,
IWorldGenerationAvailability? availability = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
_entityScriptActivator = entityScriptActivator;
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
@ -151,7 +154,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
_loaded.ContainsKey(landblockId)
&& (_wbSpawnAdapter?.IsLandblockRenderReady(landblockId) ?? true);
public bool IsLiveEntityVisible(uint serverGuid) =>
serverGuid != 0 && _visibleLiveProjectionCounts.ContainsKey(serverGuid);
_availability.IsWorldAvailable
&& serverGuid != 0
&& _visibleLiveProjectionCounts.ContainsKey(serverGuid);
/// <summary>
/// Try to grab the loaded record for a landblock — useful for callers
@ -217,6 +222,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
{
get
{
if (!_availability.IsWorldAvailable)
yield break;
foreach (var kvp in _loaded)
{
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
@ -232,6 +240,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> EnumerateLandblockEntries(
bool includeAnimatedIndex)
{
if (!_availability.IsWorldAvailable)
yield break;
foreach (var kvp in _loaded)
{
IReadOnlyDictionary<uint, WorldEntity>? byId = null;
@ -299,6 +310,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
ArgumentNullException.ThrowIfNull(destination);
ArgumentOutOfRangeException.ThrowIfNegative(landblockRadius);
destination.Clear();
if (!_availability.IsWorldAvailable)
return;
int centerX = (int)((centerCellOrLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerCellOrLandblockId >> 16) & 0xFFu);

View file

@ -141,8 +141,9 @@ public sealed class LandblockPresentationPipeline
}
_onLandblockLoaded = onLandblockLoaded;
_ensureEnvCellMeshes = ensureEnvCellMeshes;
_retirements = new LandblockRetirementCoordinator(
_retirements = LandblockRetirementCoordinator.CreateBudgeted(
state,
retirementOwner.AdvanceOne,
retirementOwner.Advance);
}
@ -162,6 +163,7 @@ public sealed class LandblockPresentationPipeline
_staticPublisher?.Diagnostics ?? default);
public int PendingRetirementCount => _retirements.PendingCount;
internal bool UsesBudgetedRetirementSteps => _retirements.UsesBudgetedSteps;
public int PendingPublicationCount => _publications.Count;
internal bool MatchesState(GpuWorldState state) =>
@ -189,10 +191,27 @@ public sealed class LandblockPresentationPipeline
public void AdvanceRetirements() => _retirements.Advance();
public void BeginFullRetirement(uint landblockId) =>
public void AdvanceRetirements(StreamingWorkMeter meter) =>
_retirements.Advance(meter);
public void BeginFullRetirement(uint landblockId)
{
_retirements.BeginFull(landblockId);
if (_retirements.UsesBudgetedSteps)
_retirements.Advance();
}
public void BeginNearLayerRetirement(uint landblockId)
{
_retirements.BeginNearLayer(landblockId);
if (_retirements.UsesBudgetedSteps)
_retirements.Advance();
}
internal void EnqueueFullRetirement(uint landblockId) =>
_retirements.BeginFull(landblockId);
public void BeginNearLayerRetirement(uint landblockId) =>
internal void EnqueueNearLayerRetirement(uint landblockId) =>
_retirements.BeginNearLayer(landblockId);
public void PublishLoaded(LandblockStreamResult.Loaded loaded)

View file

@ -101,6 +101,58 @@ public sealed class LandblockPresentationRetirementOwner
() => _render.RemoveEnvironmentCells(ticket.LandblockId));
}
internal LandblockRetirementOperationResult AdvanceOne(
LandblockRetirementTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
bool staticOnly = ticket.Kind == LandblockRetirementKind.NearLayer;
return ticket.NextIncompleteStage switch
{
LandblockRetirementStage.EntityLighting =>
ticket.RunEntityStep(
LandblockRetirementStage.EntityLighting,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveLighting),
LandblockRetirementStage.EntityTranslucency =>
ticket.RunEntityStep(
LandblockRetirementStage.EntityTranslucency,
entity => !staticOnly || entity.ServerGuid == 0,
RemoveTranslucency),
LandblockRetirementStage.PluginProjection =>
ticket.RunEntityStep(
LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
_staticPresentation.RemovePluginProjection),
LandblockRetirementStage.Terrain =>
ticket.RunOnceStep(
LandblockRetirementStage.Terrain,
() => _render.RemoveTerrain(ticket.LandblockId)),
LandblockRetirementStage.Physics =>
ticket.RunOnceStep(
LandblockRetirementStage.Physics,
() =>
{
if (ticket.Kind == LandblockRetirementKind.Full)
_physics.RemoveLandblock(ticket.LandblockId);
else
_physics.DemoteToTerrain(ticket.LandblockId);
}),
LandblockRetirementStage.CellVisibility =>
ticket.RunOnceStep(
LandblockRetirementStage.CellVisibility,
() => _render.RemoveCellVisibility(ticket.LandblockId)),
LandblockRetirementStage.BuildingRegistry =>
ticket.RunOnceStep(
LandblockRetirementStage.BuildingRegistry,
() => _render.RemoveBuildingRegistry(ticket.LandblockId)),
LandblockRetirementStage.EnvironmentCells =>
ticket.RunOnceStep(
LandblockRetirementStage.EnvironmentCells,
() => _render.RemoveEnvironmentCells(ticket.LandblockId)),
_ => LandblockRetirementOperationResult.NoWork,
};
}
private void RemoveLighting(WorldEntity entity)
{
if (entity.ServerGuid == 0)

View file

@ -20,6 +20,13 @@ public enum LandblockRetirementStage : ushort
LegacyPresentation = 1 << 11,
}
internal enum LandblockRetirementOperationResult : byte
{
NoWork,
Progressed,
Failed,
}
/// <summary>
/// One retryable landblock-retirement ledger. Successful owner operations are
/// committed once; a later attempt resumes at the exact failed owner/entity.
@ -70,6 +77,29 @@ public sealed class LandblockRetirementTicket
}
}
internal LandblockRetirementOperationResult RunOnceStep(
LandblockRetirementStage stage,
Action operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return LandblockRetirementOperationResult.NoWork;
try
{
operation();
CompletedStages |= stage;
_failures.Remove(stage);
return LandblockRetirementOperationResult.Progressed;
}
catch (Exception error)
{
_failures[stage] = error;
return LandblockRetirementOperationResult.Failed;
}
}
public bool RunForEachEntity(
LandblockRetirementStage stage,
Func<WorldEntity, bool> predicate,
@ -109,6 +139,74 @@ public sealed class LandblockRetirementTicket
return true;
}
/// <summary>
/// Advances exactly one entity cursor, including predicate misses. This
/// makes both owner work and the scan needed to find it visible to the
/// frame budget.
/// </summary>
internal LandblockRetirementOperationResult RunEntityStep(
LandblockRetirementStage stage,
Func<WorldEntity, bool> predicate,
Action<WorldEntity> operation)
{
ValidateSingleStage(stage);
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(operation);
if ((CompletedStages & stage) != 0)
return LandblockRetirementOperationResult.NoWork;
_entityCursors.TryGetValue(stage, out int cursor);
if (cursor >= Entities.Count)
{
CompletedStages |= stage;
_entityCursors.Remove(stage);
_failures.Remove(stage);
return LandblockRetirementOperationResult.Progressed;
}
WorldEntity entity = Entities[cursor];
if (predicate(entity))
{
try
{
operation(entity);
}
catch (Exception error)
{
_entityCursors[stage] = cursor;
_failures[stage] = error;
return LandblockRetirementOperationResult.Failed;
}
}
cursor++;
if (cursor == Entities.Count)
{
CompletedStages |= stage;
_entityCursors.Remove(stage);
_failures.Remove(stage);
}
else
{
_entityCursors[stage] = cursor;
}
return LandblockRetirementOperationResult.Progressed;
}
internal LandblockRetirementStage NextIncompleteStage
{
get
{
ushort remaining = (ushort)(RequiredStages & ~CompletedStages);
if (remaining == 0)
return LandblockRetirementStage.None;
ushort lowest = (ushort)(remaining & (ushort)-(short)remaining);
return (LandblockRetirementStage)lowest;
}
}
internal void BeginAttempt() => _callbackFailure = null;
internal void RecordCallbackFailure(Exception error) =>
@ -170,9 +268,13 @@ public sealed class LandblockRetirementCoordinator
private readonly GpuWorldState _state;
private readonly Action<LandblockRetirementTicket> _advancePresentation;
private readonly Func<
LandblockRetirementTicket,
LandblockRetirementOperationResult>? _advancePresentationStep;
private readonly Func<LandblockRetirementKind, LandblockRetirementStage>
_requiredPresentationStages;
private readonly Dictionary<uint, List<LandblockRetirementTicket>> _pending = new();
private readonly Queue<LandblockRetirementTicket> _pendingOrder = new();
private readonly List<uint> _completedIds = new();
private readonly Queue<Request> _requests = new();
private readonly HashSet<Request> _seenDuringDrain = new();
@ -187,12 +289,30 @@ public sealed class LandblockRetirementCoordinator
ArgumentNullException.ThrowIfNull(advancePresentation);
_state = state;
_advancePresentation = advancePresentation;
_advancePresentationStep = null;
_requiredPresentationStages = requiredPresentationStages
?? (kind => kind == LandblockRetirementKind.Full
? ProductionPresentationStages
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
}
private LandblockRetirementCoordinator(
GpuWorldState state,
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
advancePresentationStep,
Action<LandblockRetirementTicket> advancePresentation,
Func<LandblockRetirementKind, LandblockRetirementStage>
requiredPresentationStages)
{
_state = state ?? throw new ArgumentNullException(nameof(state));
_advancePresentationStep = advancePresentationStep
?? throw new ArgumentNullException(nameof(advancePresentationStep));
_advancePresentation = advancePresentation
?? throw new ArgumentNullException(nameof(advancePresentation));
_requiredPresentationStages = requiredPresentationStages
?? throw new ArgumentNullException(nameof(requiredPresentationStages));
}
public int PendingCount { get; private set; }
public bool IsPending(uint landblockId) =>
@ -200,6 +320,7 @@ public sealed class LandblockRetirementCoordinator
internal bool MatchesState(GpuWorldState state) =>
ReferenceEquals(_state, state);
internal bool UsesBudgetedSteps => _advancePresentationStep is not null;
public void BeginFull(uint landblockId) => EnqueueRequest(
new Request(RequestKind.BeginFull, Canonicalize(landblockId)));
@ -210,8 +331,81 @@ public sealed class LandblockRetirementCoordinator
public void Advance() => EnqueueRequest(
new Request(RequestKind.Advance, 0u));
internal static LandblockRetirementCoordinator CreateBudgeted(
GpuWorldState state,
Func<LandblockRetirementTicket, LandblockRetirementOperationResult>
advancePresentationStep,
Action<LandblockRetirementTicket> advancePresentation)
{
return new LandblockRetirementCoordinator(
state,
advancePresentationStep,
advancePresentation,
kind => kind == LandblockRetirementKind.Full
? ProductionPresentationStages
: ProductionPresentationStages & ~LandblockRetirementStage.Terrain);
}
/// <summary>
/// Advances stable FIFO retirement work under the caller's one frame meter.
/// Every reservation maps to exactly one entity cursor or owner stage.
/// </summary>
public void Advance(StreamingWorkMeter meter)
{
ArgumentNullException.ThrowIfNull(meter);
if (_advancePresentationStep is null)
{
Advance();
return;
}
while (_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
{
if (ticket.IsComplete)
{
RemoveCompletedTicket(ticket);
continue;
}
LandblockRetirementStage stage = ticket.NextIncompleteStage;
if (stage == LandblockRetirementStage.None)
throw new InvalidOperationException(
"An incomplete retirement ticket has no unfinished stage.");
StreamingWorkCost cost = new(
EntityOperations: 1,
GlRetireOperations:
stage is LandblockRetirementStage.MeshReferences
or LandblockRetirementStage.Terrain
? 1
: 0);
StreamingWorkAdmission admission = meter.TryReserve(
cost,
$"retire-{stage}-0x{ticket.LandblockId:X8}");
if (admission == StreamingWorkAdmission.Yielded)
return;
LandblockRetirementOperationResult result = AdvanceTicketOne(ticket);
if (result == LandblockRetirementOperationResult.Failed)
{
meter.Fail();
return;
}
meter.Complete();
if (ticket.IsComplete)
RemoveCompletedTicket(ticket);
}
}
private void AdvanceCore()
{
if (_advancePresentationStep is not null)
{
AdvanceBudgetedEagerAttempt();
return;
}
if (_pending.Count == 0)
return;
@ -278,6 +472,9 @@ public sealed class LandblockRetirementCoordinator
if (existing[i].Kind == kind)
{
LandblockRetirementTicket existingTicket = existing[i];
if (_advancePresentationStep is not null)
return;
AdvanceTicket(existingTicket);
if (existingTicket.IsComplete)
{
@ -331,14 +528,19 @@ public sealed class LandblockRetirementCoordinator
}
existing.Add(ticket);
PendingCount++;
if (_advancePresentationStep is not null)
_pendingOrder.Enqueue(ticket);
AdvanceTicket(ticket);
if (ticket.IsComplete)
if (_advancePresentationStep is null)
{
existing.Remove(ticket);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
AdvanceTicket(ticket);
if (ticket.IsComplete)
{
existing.Remove(ticket);
PendingCount--;
if (existing.Count == 0)
_pending.Remove(canonical);
}
}
// Visibility is a delivered-by-attempt broadcast: every subscriber is
// invoked and failures are aggregated, but failed subscribers are not
@ -380,6 +582,96 @@ public sealed class LandblockRetirementCoordinator
}
}
private void AdvanceBudgetedEagerAttempt()
{
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? ticket))
return;
if (ticket.IsComplete)
{
RemoveCompletedTicket(ticket);
return;
}
// Compatibility/debug callers explicitly asking for an unmetered
// advance retain the former one-attempt behavior: every independent
// stage is attempted once, while successful stages/entities remain
// committed in the same exact ticket. Production calls the metered
// overload and never enters this path.
AdvanceTicket(ticket);
if (ticket.IsComplete)
RemoveCompletedTicket(ticket);
}
private LandblockRetirementOperationResult AdvanceTicketOne(
LandblockRetirementTicket ticket)
{
ticket.BeginAttempt();
LandblockRetirementStage stage = ticket.NextIncompleteStage;
LandblockRetirementOperationResult result;
try
{
result = stage switch
{
LandblockRetirementStage.MeshReferences =>
ticket.RunOnceStep(
stage,
() => _state.ReleaseLandblockMeshReferences(
ticket.LandblockId)),
LandblockRetirementStage.StaticScripts =>
ticket.RunEntityStep(
stage,
static entity => entity.ServerGuid == 0,
_state.StopStaticEntityScript),
LandblockRetirementStage.Classification =>
ticket.RunOnceStep(
stage,
() => _state.InvalidateLandblockClassification(
ticket.LandblockId)),
LandblockRetirementStage.None =>
LandblockRetirementOperationResult.NoWork,
_ => _advancePresentationStep!(ticket),
};
}
catch (Exception error)
{
ticket.RecordCallbackFailure(error);
result = LandblockRetirementOperationResult.Failed;
}
if (ticket.TryTakeNewFailure(out Exception? failure))
{
Console.WriteLine(
$"streaming: retirement for 0x{ticket.LandblockId:X8} " +
$"({ticket.Kind}) remains pending: {failure}");
}
return result;
}
private void RemoveCompletedTicket(LandblockRetirementTicket ticket)
{
if (!_pendingOrder.TryPeek(out LandblockRetirementTicket? head)
|| !ReferenceEquals(head, ticket))
{
throw new InvalidOperationException(
"Retirement FIFO completion did not match its active head.");
}
_pendingOrder.Dequeue();
uint id = Canonicalize(ticket.LandblockId);
if (!_pending.TryGetValue(id, out List<LandblockRetirementTicket>? tickets)
|| !tickets.Remove(ticket))
{
throw new InvalidOperationException(
"Completed retirement ticket is missing from its owner ledger.");
}
PendingCount--;
if (tickets.Count == 0)
_pending.Remove(id);
}
private static uint Canonicalize(uint id) =>
(id & 0xFFFF0000u) | 0xFFFFu;

View file

@ -674,6 +674,13 @@ internal sealed class LocalPlayerTeleportController
+ $"new lb=({landblockX},{landblockY}) "
+ $"dist={Vector3.Distance(translated, controller.Position):F1}");
// Retail SmartBox enters blocking_for_cells before old-world object,
// physics, landscape, and ambient owners may advance again. Begin the
// reveal generation before a recenter can start detaching that world.
_worldReveal.Begin(WorldRevealKind.Portal);
if (!IsCurrentLifetime(generation, sequence))
return false;
Vector3 worldPosition;
if (transition.ChangesStreamingCenter)
{
@ -707,9 +714,6 @@ internal sealed class LocalPlayerTeleportController
_pendingCell = position.LandblockId;
_holdSeconds = 0f;
_forced = false;
_worldReveal.Begin(WorldRevealKind.Portal);
if (!IsCurrentLifetime(generation, sequence))
return false;
_streaming.SetPriority(
StreamingRegion.EncodeLandblockId(landblockX, landblockY),
NearRingRadius);
@ -746,7 +750,7 @@ internal sealed class LocalPlayerTeleportController
_holdSeconds = 0f;
_forced = false;
bool originConverged = _streaming.ResetRecenter(clearSession);
_streaming.ResetRecenter(clearSession);
if (_lifetimeGeneration != generation)
return generation;
@ -763,12 +767,6 @@ internal sealed class LocalPlayerTeleportController
_streaming.ClearPriority();
if (clearSession && !originConverged)
{
throw new InvalidOperationException(
"The ending session's streaming-origin retirement has not converged.");
}
return generation;
}

View file

@ -167,6 +167,21 @@ internal sealed class StreamingCompletionQueue
_retainedCpuBytes = 0;
}
public bool TryRemoveOne()
{
for (int priority = 0; priority < _queues.Length; priority++)
{
Queue<StreamingQueuedCompletion> queue = _queues[priority];
if (!queue.TryDequeue(out StreamingQueuedCompletion completion))
continue;
Release(completion);
return true;
}
return false;
}
public StreamingCompletionQueueSnapshot CaptureSnapshot()
{
long now = Stopwatch.GetTimestamp();

View file

@ -29,11 +29,24 @@ public sealed class StreamingController : IStreamingFrameBackend
public List<uint>? ResidentIds;
public int RetirementCursor;
public bool PreparationCommitted;
public IEnumerator<uint>? ResidentEnumerator;
public (int X, int Y, bool IsSealedDungeon)? Destination;
public bool DestinationConfigured;
public bool DestinationLoadEnqueued;
}
private sealed class FullWindowRetirement
{
public bool GenerationAdvanced;
public bool PendingLoadsCleared;
public bool CompletionQueueCleared;
public bool RegionCleared;
public List<uint>? ResidentIds;
public IEnumerator<uint>? ResidentEnumerator;
public int RetirementCursor;
public bool PreparationCommitted;
}
private readonly Action<uint, LandblockStreamJobKind, ulong> _enqueueLoad;
private readonly Action<uint, ulong> _enqueueUnload;
private readonly ILandblockCompletionSource _completionSource;
@ -55,6 +68,7 @@ public sealed class StreamingController : IStreamingFrameBackend
private bool _advancingRadiiReconfiguration;
private (int NearRadius, int FarRadius)? _deferredRadiiRequest;
private OriginRecenterRetirement? _originRecenterRetirement;
private FullWindowRetirement? _fullWindowRetirement;
private bool _advancingOriginRecenter;
// Hard streaming boundaries advance this token. Worker completions carry
// the generation captured at enqueue time, so an old overlapping load or
@ -459,7 +473,7 @@ public sealed class StreamingController : IStreamingFrameBackend
private void DemoteLandblock(uint id)
{
uint canonical = (id & 0xFFFF0000u) | 0xFFFFu;
_presentation.BeginNearLayerRetirement(canonical);
_presentation.EnqueueNearLayerRetirement(canonical);
}
private bool AdvanceGeneration()
@ -530,9 +544,37 @@ public sealed class StreamingController : IStreamingFrameBackend
_activeWorkMeter = meter;
try
{
if (_fullWindowRetirement is not null)
{
bool retirementWasPending =
_presentation.PendingRetirementCount != 0;
TryAdvanceFullWindowRetirement(meter);
if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending)
{
_presentation.AdvanceRetirements(meter);
}
if (_fullWindowRetirement is
{
PreparationCommitted: true,
}
&& _presentation.PendingRetirementCount == 0)
{
_fullWindowRetirement = null;
}
return;
}
if (_originRecenterRetirement is not null)
{
_presentation.AdvanceRetirements();
bool retirementWasPending =
_presentation.PendingRetirementCount != 0;
TryAdvanceOriginRecenterPreparation(meter);
if (_presentation.UsesBudgetedRetirementSteps
|| retirementWasPending)
{
_presentation.AdvanceRetirements(meter);
}
return;
}
@ -544,12 +586,12 @@ public sealed class StreamingController : IStreamingFrameBackend
ReconfigureRadii(deferred.NearRadius, deferred.FarRadius);
}
// Presentation-owner failures never roll back spatial state. Resume
// unfinished owner ledgers before accepting replacement publications.
_presentation.AdvanceRetirements();
// Complete an admitted publication before this frame can mutate its
// desired tier or spatial residence. A later recenter may then demote
// or retire the fully known owner set through the normal ledger.
bool retirementWasPendingAtFrameStart =
_presentation.PendingRetirementCount != 0;
_presentation.AdvanceRetirements(meter);
if (!ConvergePendingPublications())
return;
@ -584,6 +626,12 @@ public sealed class StreamingController : IStreamingFrameBackend
}
DrainAndApply();
// Retirement is cleanup after immediate spatial withdrawal. Spend
// remaining capacity only after visible/destination publication
// work has had access to this frame's shared meter.
if (_presentation.UsesBudgetedRetirementSteps
&& !retirementWasPendingAtFrameStart)
_presentation.AdvanceRetirements(meter);
}
finally
{
@ -882,10 +930,13 @@ public sealed class StreamingController : IStreamingFrameBackend
}
/// <summary>
/// Starts a reload of the current streaming window. Logical residency is
/// detached immediately; render, physics, and static-lighting owners then
/// converge through the retained presentation-retirement ledger before
/// <see cref="NormalTick"/> bootstraps the window again.
/// Starts a reload of the current streaming window. The next and later
/// <see cref="Tick"/> calls invalidate accepted work, capture and detach
/// stable residents, and converge render, physics, and static-lighting
/// owners under the shared frame budget before <see cref="NormalTick"/>
/// bootstraps the window again. Hard login/portal transitions separately
/// publish their immediate observable edge through
/// <see cref="WorldGenerationQuiescence"/>.
/// Shared-origin teleports use <see cref="BeginOriginRecenter"/> instead so
/// every old-window owner retires while the old coordinate frame is active.
/// </summary>
@ -916,10 +967,8 @@ public sealed class StreamingController : IStreamingFrameBackend
throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
if (!TryAdvanceOriginRecenterPreparation())
return false;
_presentation.AdvanceRetirements();
return _presentation.PendingRetirementCount == 0;
return _originRecenterRetirement.PreparationCommitted
&& _presentation.PendingRetirementCount == 0;
}
/// <summary>
@ -1040,8 +1089,9 @@ public sealed class StreamingController : IStreamingFrameBackend
return true;
}
private bool TryAdvanceOriginRecenterPreparation()
private bool TryAdvanceOriginRecenterPreparation(StreamingWorkMeter meter)
{
ArgumentNullException.ThrowIfNull(meter);
OriginRecenterRetirement transaction = _originRecenterRetirement
?? throw new InvalidOperationException(
"No streaming-origin recenter transaction is pending.");
@ -1055,58 +1105,159 @@ public sealed class StreamingController : IStreamingFrameBackend
{
if (!transaction.RadiiConverged)
{
if (_pendingRadiiReconfiguration is not null)
AdvanceRadiiReconfiguration();
if (_pendingRadiiReconfiguration is not null
&& !TryRunStreamingWork(
meter,
new StreamingWorkCost(
EntityOperations: Math.Max(
1,
_pendingRadiiReconfiguration.Mutations.Count)),
"recenter-radii-convergence",
() =>
{
AdvanceRadiiReconfiguration();
return _pendingRadiiReconfiguration is null;
}))
{
return false;
}
if (_pendingRadiiReconfiguration is not null)
return false;
transaction.RadiiConverged = true;
}
if (!transaction.GenerationAdvanced)
{
// AdvanceGeneration meters any retained publication it must
// converge through the active frame meter. The token increment
// itself is constant-time and must not create a nested meter
// reservation around that retry.
if (!AdvanceGeneration())
return false;
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
{
try
bool ClearPendingLoads()
{
_clearPendingLoads?.Invoke();
transaction.PendingLoadsCleared = true;
try
{
_clearPendingLoads?.Invoke();
transaction.PendingLoadsCleared = true;
return true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.PendingLoadsCleared = true;
Console.WriteLine(
$"streaming: committed pending-load clear reported failure: {error}");
return false;
}
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.PendingLoadsCleared = true;
Console.WriteLine(
$"streaming: committed pending-load clear reported failure: {error}");
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"recenter-clear-worker-inbox",
ClearPendingLoads))
return false;
}
}
if (!transaction.CompletionQueueCleared)
{
_completionQueue.Clear();
while (_completionQueue.Count != 0)
{
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1),
"recenter-release-completion");
if (admission == StreamingWorkAdmission.Yielded)
return false;
if (!_completionQueue.TryRemoveOne())
{
meter.Fail();
throw new InvalidOperationException(
"Completion queue count changed during recenter release.");
}
meter.Complete();
}
transaction.CompletionQueueCleared = true;
}
if (!transaction.RegionCleared)
{
_collapsed = false;
_region = null;
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"recenter-clear-region",
() =>
{
_collapsed = false;
_region = null;
return true;
}))
{
return false;
}
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
transaction.ResidentIds = new List<uint>(_state.LoadedLandblockIds);
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount = transaction.ResidentIds.Count;
transaction.ResidentIds = [];
transaction.ResidentEnumerator =
_state.LoadedLandblockIds.GetEnumerator();
}
while (transaction.RetirementCursor < transaction.ResidentIds.Count)
while (transaction.ResidentEnumerator is { } residentEnumerator)
{
uint id = transaction.ResidentIds[transaction.RetirementCursor];
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1),
"recenter-capture-resident-id");
if (admission == StreamingWorkAdmission.Yielded)
return false;
bool moved;
try
{
_presentation.BeginFullRetirement(id);
moved = residentEnumerator.MoveNext();
if (moved)
transaction.ResidentIds.Add(residentEnumerator.Current);
else
{
residentEnumerator.Dispose();
transaction.ResidentEnumerator = null;
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount =
transaction.ResidentIds.Count;
}
meter.Complete();
}
catch
{
meter.Fail();
throw;
}
}
List<uint> residentIds = transaction.ResidentIds
?? throw new InvalidOperationException(
"Origin-recenter resident capture did not commit.");
while (transaction.RetirementCursor < residentIds.Count)
{
uint id = residentIds[transaction.RetirementCursor];
int entityCount = _state.TryGetLandblock(
id,
out LoadedLandblock? loaded)
? loaded!.Entities.Count
: 0;
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(
EntityOperations: Math.Max(1, entityCount)),
$"recenter-detach-0x{id:X8}");
if (admission == StreamingWorkAdmission.Yielded)
return false;
try
{
_presentation.EnqueueFullRetirement(id);
transaction.RetirementCursor++;
meter.Complete();
}
catch (Exception error)
{
@ -1116,6 +1267,7 @@ public sealed class StreamingController : IStreamingFrameBackend
// otherwise retain it for the next frame.
if (!_state.IsLoaded(id))
transaction.RetirementCursor++;
meter.Fail();
Console.WriteLine(
$"streaming: origin-recenter retirement for 0x{id:X8} " +
$"will resume: {error}");
@ -1140,21 +1292,178 @@ public sealed class StreamingController : IStreamingFrameBackend
private void BeginFullWindowRetirement()
{
if (!AdvanceGeneration())
return;
_collapsed = false;
_clearPendingLoads?.Invoke();
_completionQueue.Clear();
// Commit the old region boundary before any presentation owner is
// advanced. New same-id publications are fenced by the presentation
// pipeline's retryable retirement ledger.
_region = null;
// Snapshot — RemoveLandblock mutates the loaded set we're iterating.
var ids = new List<uint>(_state.LoadedLandblockIds);
FullWindowRetirementCount++; // [FRAME-DIAG] churn counter
LastFullWindowRetirementLandblockCount = ids.Count; // upcoming re-upload volume
foreach (var id in ids)
_presentation.BeginFullRetirement(id);
_fullWindowRetirement ??= new FullWindowRetirement();
}
private bool TryAdvanceFullWindowRetirement(StreamingWorkMeter meter)
{
FullWindowRetirement transaction = _fullWindowRetirement
?? throw new InvalidOperationException(
"No full-window retirement transaction is pending.");
if (transaction.PreparationCommitted)
return true;
try
{
if (!transaction.GenerationAdvanced)
{
if (!AdvanceGeneration())
{
return false;
}
transaction.GenerationAdvanced = true;
}
if (!transaction.PendingLoadsCleared)
{
bool ClearPendingLoads()
{
try
{
_clearPendingLoads?.Invoke();
transaction.PendingLoadsCleared = true;
return true;
}
catch (StreamingMutationException error) when (error.MutationCommitted)
{
transaction.PendingLoadsCleared = true;
Console.WriteLine(
$"streaming: committed reload pending-load clear reported failure: {error}");
return false;
}
}
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"reload-clear-worker-inbox",
ClearPendingLoads))
{
return false;
}
}
if (!transaction.CompletionQueueCleared)
{
while (_completionQueue.Count != 0)
{
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1),
"reload-release-completion");
if (admission == StreamingWorkAdmission.Yielded)
return false;
if (!_completionQueue.TryRemoveOne())
{
meter.Fail();
throw new InvalidOperationException(
"Completion queue count changed during reload release.");
}
meter.Complete();
}
transaction.CompletionQueueCleared = true;
}
if (!transaction.RegionCleared)
{
if (!TryRunStreamingWork(
meter,
new StreamingWorkCost(EntityOperations: 1),
"reload-clear-region",
() =>
{
_collapsed = false;
_region = null;
return true;
}))
{
return false;
}
transaction.RegionCleared = true;
}
if (transaction.ResidentIds is null)
{
transaction.ResidentIds = [];
transaction.ResidentEnumerator =
_state.LoadedLandblockIds.GetEnumerator();
}
while (transaction.ResidentEnumerator is { } residentEnumerator)
{
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(EntityOperations: 1),
"reload-capture-resident-id");
if (admission == StreamingWorkAdmission.Yielded)
return false;
bool moved;
try
{
moved = residentEnumerator.MoveNext();
if (moved)
transaction.ResidentIds.Add(residentEnumerator.Current);
else
{
residentEnumerator.Dispose();
transaction.ResidentEnumerator = null;
FullWindowRetirementCount++;
LastFullWindowRetirementLandblockCount =
transaction.ResidentIds.Count;
}
meter.Complete();
}
catch
{
meter.Fail();
throw;
}
}
List<uint> residentIds = transaction.ResidentIds
?? throw new InvalidOperationException(
"Full-window resident capture did not commit.");
while (transaction.RetirementCursor < residentIds.Count)
{
uint id = residentIds[transaction.RetirementCursor];
int entityCount = _state.TryGetLandblock(
id,
out LoadedLandblock? loaded)
? loaded!.Entities.Count
: 0;
StreamingWorkAdmission admission = meter.TryReserve(
new StreamingWorkCost(
EntityOperations: Math.Max(1, entityCount)),
$"reload-detach-0x{id:X8}");
if (admission == StreamingWorkAdmission.Yielded)
return false;
try
{
_presentation.EnqueueFullRetirement(id);
transaction.RetirementCursor++;
meter.Complete();
}
catch (Exception error)
{
if (!_state.IsLoaded(id))
transaction.RetirementCursor++;
meter.Fail();
Console.WriteLine(
$"streaming: full-window retirement for 0x{id:X8} " +
$"will resume: {error}");
return false;
}
}
transaction.PreparationCommitted = true;
return true;
}
catch (Exception error)
{
Console.WriteLine(
$"streaming: full-window preparation will resume: {error}");
return false;
}
}
/// <summary>
@ -1209,6 +1518,32 @@ public sealed class StreamingController : IStreamingFrameBackend
}
}
private static bool TryRunStreamingWork(
StreamingWorkMeter meter,
StreamingWorkCost cost,
string stage,
Func<bool> operation)
{
StreamingWorkAdmission admission = meter.TryReserve(cost, stage);
if (admission == StreamingWorkAdmission.Yielded)
return false;
try
{
bool completed = operation();
if (completed)
meter.Complete();
else
meter.Fail();
return completed;
}
catch
{
meter.Fail();
throw;
}
}
private void AdmitCompletions(StreamingWorkMeter meter)
{
while (_completionSource.TryPeek(out LandblockStreamResult? peeked))
@ -1439,7 +1774,7 @@ public sealed class StreamingController : IStreamingFrameBackend
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
break;
case LandblockStreamResult.Unloaded unloaded:
_presentation.BeginFullRetirement(unloaded.LandblockId);
_presentation.EnqueueFullRetirement(unloaded.LandblockId);
break;
case LandblockStreamResult.Failed failed:
Console.WriteLine(
@ -1458,14 +1793,14 @@ public sealed class StreamingController : IStreamingFrameBackend
|| !_region.TryGetDesiredTier(landblockId, out LandblockStreamTier desiredTier))
{
if (_state.IsLoaded(landblockId))
_presentation.BeginFullRetirement(landblockId);
_presentation.EnqueueFullRetirement(landblockId);
return;
}
if (desiredTier == LandblockStreamTier.Far
&& _state.IsNearTier(landblockId))
{
_presentation.BeginNearLayerRetirement(landblockId);
_presentation.EnqueueNearLayerRetirement(landblockId);
}
}

View file

@ -37,9 +37,10 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
public bool IsPending => _pending is not null;
/// <summary>
/// Begins a new recenter transaction and performs its first retirement
/// attempt immediately. A teleport replacement must call <see cref="Reset"/>
/// before supplying a different destination.
/// Begins a new recenter transaction. Its retained preparation and
/// retirement cursors advance only from <see cref="StreamingController.Tick"/>
/// under that frame's shared work meter. A teleport replacement must call
/// <see cref="Reset"/> before supplying a different destination.
/// </summary>
public bool Begin(int destinationX, int destinationY, bool isSealedDungeon)
{
@ -71,8 +72,9 @@ internal sealed class StreamingOriginRecenterCoordinator : IStreamingOriginConve
}
/// <summary>
/// Advances retained presentation teardown. Returns true only after the
/// new origin is committed and destination streaming has been unblocked.
/// Observes retained presentation teardown and commits the new origin once
/// the streaming frame owner has converged it. Returns true only after the
/// destination streaming gate has been released.
/// </summary>
public bool Advance()
{

View file

@ -0,0 +1,109 @@
using AcDream.App.Audio;
using AcDream.Core.Selection;
namespace AcDream.App.Streaming;
/// <summary>
/// Read-only generation gate shared by world simulation and presentation.
/// A hard login/portal reveal boundary makes the prior world unavailable
/// immediately while its physical owners retire over later frames.
/// </summary>
public interface IWorldGenerationAvailability
{
bool IsWorldAvailable { get; }
long QuiescedGeneration { get; }
}
internal sealed class AlwaysAvailableWorldGeneration
: IWorldGenerationAvailability
{
public static AlwaysAvailableWorldGeneration Instance { get; } = new();
public bool IsWorldAvailable => true;
public long QuiescedGeneration => 0;
}
/// <summary>
/// Single-writer state behind <see cref="IWorldGenerationAvailability"/>.
/// Superseding reveal generations stay continuously quiesced; only the exact
/// active generation may reopen the world.
/// </summary>
internal sealed class WorldGenerationAvailabilityState
: IWorldGenerationAvailability
{
public bool IsWorldAvailable => QuiescedGeneration == 0;
public long QuiescedGeneration { get; private set; }
public void Begin(long generation)
{
if (generation <= 0)
throw new ArgumentOutOfRangeException(nameof(generation));
QuiescedGeneration = generation;
}
public bool End(long generation)
{
if (generation <= 0 || QuiescedGeneration != generation)
return false;
QuiescedGeneration = 0;
return true;
}
}
/// <summary>
/// Owns the immediate observable edge of retail
/// <c>SmartBox::UseTime @ 0x00455410</c>'s <c>blocking_for_cells</c>
/// branch. Deferred teardown may retain memory, but the old generation cannot
/// keep rendering, targeting, ticking, or producing world audio.
/// </summary>
internal sealed class WorldGenerationQuiescence
{
private readonly WorldGenerationAvailabilityState _availability;
private readonly SelectionState _selection;
private readonly GpuWorldState _world;
private readonly IWorldAudioQuiescence? _audio;
public WorldGenerationQuiescence(
WorldGenerationAvailabilityState availability,
SelectionState selection,
GpuWorldState world,
IWorldAudioQuiescence? audio)
{
_availability = availability
?? throw new ArgumentNullException(nameof(availability));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_world = world ?? throw new ArgumentNullException(nameof(world));
_audio = audio;
}
public IWorldGenerationAvailability Availability => _availability;
public void Begin(long generation)
{
bool firstEdge = _availability.IsWorldAvailable;
uint? selected = firstEdge ? _selection.SelectedObjectId : null;
bool clearWorldSelection =
selected is uint selectedGuid
&& _world.IsLiveEntityVisible(selectedGuid);
_availability.Begin(generation);
if (!firstEdge)
return;
if (clearWorldSelection)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.Cleared);
}
_audio?.SuspendWorldAudio();
}
public void End(long generation)
{
if (_availability.End(generation))
_audio?.ResumeWorldAudio();
}
}

View file

@ -10,6 +10,8 @@ internal sealed class WorldRevealCoordinator
{
private readonly WorldRevealReadinessBarrier _readiness;
private readonly WorldRevealLifecycleTelemetry _lifecycle;
private readonly WorldGenerationQuiescence? _quiescence;
private long _activeGeneration;
public WorldRevealCoordinator(
Func<uint, int, bool> isRenderNeighborhoodReady,
@ -19,7 +21,8 @@ internal sealed class WorldRevealCoordinator
Action<uint, int> prepareCompositeTextures,
Action invalidateCompositeTextures,
Func<uint, bool> isSpawnClaimUnhydratable,
Action<string>? log = null)
Action<string>? log = null,
WorldGenerationQuiescence? quiescence = null)
{
_readiness = new WorldRevealReadinessBarrier(
isRenderNeighborhoodReady,
@ -30,6 +33,7 @@ internal sealed class WorldRevealCoordinator
invalidateCompositeTextures,
isSpawnClaimUnhydratable);
_lifecycle = new WorldRevealLifecycleTelemetry(log);
_quiescence = quiescence;
}
public WorldRevealLifecycleSnapshot Snapshot => _lifecycle.Snapshot;
@ -38,7 +42,10 @@ internal sealed class WorldRevealCoordinator
public long Begin(WorldRevealKind kind)
{
_readiness.Begin();
return _lifecycle.Begin(kind);
long generation = _lifecycle.Begin(kind);
_activeGeneration = generation;
_quiescence?.Begin(generation);
return generation;
}
/// <summary>
@ -63,7 +70,25 @@ internal sealed class WorldRevealCoordinator
public void ObserveWorldViewportVisible() =>
_lifecycle.ObserveWorldViewportVisible();
public void Complete() => _lifecycle.Complete();
public void Complete()
{
_lifecycle.Complete();
EndQuiescence();
}
public void Cancel() => _lifecycle.Cancel();
public void Cancel()
{
_lifecycle.Cancel();
EndQuiescence();
}
private void EndQuiescence()
{
long generation = _activeGeneration;
if (generation == 0)
return;
_activeGeneration = 0;
_quiescence?.End(generation);
}
}

View file

@ -1,5 +1,7 @@
namespace AcDream.App.Update;
using AcDream.App.Streaming;
/// <summary>
/// The host-supplied input for one update callback.
/// </summary>
@ -28,10 +30,13 @@ internal sealed class UpdateFrameClock : IPhysicsScriptTimeSource
{
public double CurrentScriptTime { get; private set; }
public UpdateFrameTiming Advance(UpdateFrameInput input)
public UpdateFrameTiming Advance(
UpdateFrameInput input,
bool advanceScriptClock = true)
{
double deltaSeconds = NormalizeDeltaSeconds(input.HostDeltaSeconds);
CurrentScriptTime += deltaSeconds;
if (advanceScriptClock)
CurrentScriptTime += deltaSeconds;
return new UpdateFrameTiming(
deltaSeconds,
(float)deltaSeconds,
@ -122,6 +127,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
private readonly ILocalPlayerTeleportFramePhase _teleport;
private readonly IPlayerModeAutoEntryFramePhase _playerModeAutoEntry;
private readonly ICameraFramePhase _camera;
private readonly IWorldGenerationAvailability _availability;
public UpdateFrameOrchestrator(
IUpdateFrameTeardownPhase teardown,
@ -134,7 +140,8 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
ILiveEntityLivenessFramePhase liveness,
ILocalPlayerTeleportFramePhase teleport,
IPlayerModeAutoEntryFramePhase playerModeAutoEntry,
ICameraFramePhase camera)
ICameraFramePhase camera,
IWorldGenerationAvailability? availability = null)
{
_teardown = teardown ?? throw new ArgumentNullException(nameof(teardown));
_failureSink = failureSink ?? throw new ArgumentNullException(nameof(failureSink));
@ -149,6 +156,7 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_playerModeAutoEntry = playerModeAutoEntry
?? throw new ArgumentNullException(nameof(playerModeAutoEntry));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(UpdateFrameInput input)
@ -165,12 +173,15 @@ internal sealed class UpdateFrameOrchestrator : AcDream.App.Rendering.IGameUpdat
_failureSink.ReportTeardownFailure(error);
}
UpdateFrameTiming timing = _clock.Advance(input);
UpdateFrameTiming timing = _clock.Advance(
input,
advanceScriptClock: _availability.IsWorldAvailable);
_scriptClockPublisher.PublishTime(timing.ScriptTime);
_streaming.Tick();
_input.Tick(timing);
_liveFrame.Tick(timing.SimulationDeltaSecondsSingle);
_liveness.Tick();
if (_availability.IsWorldAvailable)
_liveness.Tick();
_teleport.Tick(timing.SimulationDeltaSecondsSingle);
_playerModeAutoEntry.TryEnter();
_camera.Tick(timing);

View file

@ -24,13 +24,15 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
private readonly ILiveSessionFramePhase _session;
private readonly IPostNetworkCommandFramePhase _localPlayer;
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
private readonly IWorldGenerationAvailability _availability;
public RetailLiveFrameCoordinator(
ILiveObjectFramePhase objects,
GpuWorldState worldState,
ILiveSessionFramePhase session,
IPostNetworkCommandFramePhase localPlayer,
ILiveSpatialReconcilePhase spatialReconciler)
ILiveSpatialReconcilePhase spatialReconciler,
IWorldGenerationAvailability? availability = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
@ -38,16 +40,19 @@ internal sealed class RetailLiveFrameCoordinator : IRetailLiveFramePhase
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_spatialReconciler = spatialReconciler
?? throw new ArgumentNullException(nameof(spatialReconciler));
_availability = availability ?? AlwaysAvailableWorldGeneration.Instance;
}
public void Tick(float deltaSeconds)
{
float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds);
_objects.Tick(frameDelta);
if (_availability.IsWorldAvailable)
_objects.Tick(frameDelta);
using (_worldState.BeginMutationBatch())
_session.Tick();
_localPlayer.RunPostNetworkCommandPhase();
_spatialReconciler.Reconcile();
if (_availability.IsWorldAvailable)
_spatialReconciler.Reconcile();
}
public static double NormalizeDeltaSeconds(double deltaSeconds) =>

View file

@ -152,6 +152,13 @@ public sealed class LiveSessionResetPlanTests
Assert.Equal(1, identityResets);
failRetirement = false;
for (int frame = 0;
frame < 64
&& !controller.IsOriginRecenterRetirementComplete();
frame++)
{
controller.Tick(0x53, 0x53);
}
plan.Execute();
Assert.Equal(2, identityResets);

View file

@ -3,6 +3,7 @@ using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.App.Streaming;
using AcDream.Core.Rendering;
using AcDream.Core.World;
@ -21,6 +22,23 @@ public sealed class WorldSceneRendererTests
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
[Fact]
public void QuiescedGeneration_PublishesEmptySelectionFrameAndSkipsEveryWorldOwner()
{
var availability = new WorldGenerationAvailabilityState();
availability.Begin(4);
var rig = new Rig(
portalVisible: false,
waitingForLogin: false,
clipRoot: null,
availability: availability);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.Equal(default, result);
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
[Fact]
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
{
@ -332,7 +350,8 @@ public sealed class WorldSceneRendererTests
bool waitingForLogin,
LoadedCell? clipRoot,
bool? playerSeenOutside = null,
EnvironOverride environOverride = EnvironOverride.None)
EnvironOverride environOverride = EnvironOverride.None,
IWorldGenerationAvailability? availability = null)
{
Calls = [];
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
@ -385,7 +404,8 @@ public sealed class WorldSceneRendererTests
new PViewCells(),
Passes,
new WorldRenderRangeState(4, 12),
diagnostics);
diagnostics,
availability);
}
public List<string> Calls { get; }

View file

@ -137,6 +137,7 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x22, 0x23, isSealedDungeon: false));
controller.Tick(0x19, 0x19);
Assert.Equal((0x19, 0x19), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(centerId));
@ -144,12 +145,11 @@ public sealed class LandblockPresentationPipelineTests
Assert.Equal(1, controller.PendingRetirementCount);
Assert.Equal(2, controller.LastFullWindowRetirementLandblockCount);
controller.Tick(0x19, 0x19);
Assert.Empty(enqueued);
Assert.True(neighborAttempts >= 2);
Assert.Equal(1, neighborAttempts);
failNeighbor = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x19, 0x19));
Assert.Equal((0x22, 0x23), (origin.CenterX, origin.CenterY));
Assert.Equal(0, controller.PendingRetirementCount);
@ -187,12 +187,13 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x40, 0x41, isSealedDungeon: false));
controller.Tick(0x30, 0x30);
Assert.Equal((0x30, 0x30), (origin.CenterX, origin.CenterY));
Assert.True(state.IsLoaded(landblockId));
Assert.Equal(0, controller.PendingRetirementCount);
failClear = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x30, 0x30));
Assert.Equal(2, clearAttempts);
Assert.False(state.IsLoaded(landblockId));
@ -234,13 +235,15 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x43, 0x44, isSealedDungeon: false));
for (int frame = 0; frame < 16 && observerCalls == 0; frame++)
controller.Tick(0x42, 0x42);
Assert.Equal((0x42, 0x42), (origin.CenterX, origin.CenterY));
Assert.False(state.IsLoaded(firstId));
Assert.False(state.IsLoaded(secondId));
Assert.Equal(1, observerCalls);
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x42, 0x42));
Assert.Equal((0x43, 0x44), (origin.CenterX, origin.CenterY));
Assert.Equal([firstId, secondId], removed);
}
@ -281,13 +284,14 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x46, 0x46, isSealedDungeon: false));
controller.Tick(0x45, 0x45);
Assert.Same(player, Assert.Single(state.DrainRescued()));
Assert.Equal(1, state.PendingLiveEntityCount);
Assert.Empty(state.Entities);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x45, 0x45));
Assert.Empty(state.DrainRescued());
state.AddLandblock(new LoadedLandblock(
destinationId,
@ -344,7 +348,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(recenter.IsPending);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x50, 0x50));
Assert.False(recenter.IsPending);
Assert.Equal((0x50, 0x50), (origin.CenterX, origin.CenterY));
@ -384,7 +388,12 @@ public sealed class LandblockPresentationPipelineTests
Assert.False(recenter.Begin(0x60, 0x60, isSealedDungeon: false));
recenter.Reset();
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(
recenter,
controller,
0x52,
0x52,
insideDungeon: true));
Assert.Equal((0x52, 0x52), (origin.CenterX, origin.CenterY));
Assert.Equal([(sourceId, LandblockStreamJobKind.LoadNear)], enqueued);
@ -423,7 +432,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.False(recenter.Begin(0x54, 0x54, isSealedDungeon: true));
recenter.Reset(sessionEnding: true);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x53, 0x53));
Assert.Empty(enqueued);
Assert.Equal((0x53, 0x53), (origin.CenterX, origin.CenterY));
@ -464,7 +473,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(origin.TryInitialize(0x61, 0x62));
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x61, 0x62));
Assert.Equal((0x61, 0x62), (origin.CenterX, origin.CenterY));
}
@ -494,7 +503,7 @@ public sealed class LandblockPresentationPipelineTests
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.False(recenter.Begin(0x11, 0x11, isSealedDungeon: false));
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x10, 0x10));
Assert.Equal(1, clearAttempts);
Assert.Equal((0x11, 0x11), (origin.CenterX, origin.CenterY));
@ -537,10 +546,11 @@ public sealed class LandblockPresentationPipelineTests
removed.Add(id);
if (id == firstId)
Assert.False(controller!.IsOriginRecenterRetirementComplete());
});
});
var recenter = new StreamingOriginRecenterCoordinator(controller, origin);
Assert.True(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
Assert.False(recenter.Begin(0x71, 0x71, isSealedDungeon: false));
Assert.True(Converge(recenter, controller, 0x70, 0x70));
Assert.Equal(1, clearCalls);
Assert.Equal([firstId, secondId], removed);
@ -585,7 +595,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.Empty(enqueued);
failRetirement = false;
Assert.True(recenter.Advance());
Assert.True(Converge(recenter, controller, 0x72, 0x72));
controller.Tick(0x73, 0x73);
Assert.Equal(1, controller.NearRadius);
@ -630,6 +640,7 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(recenter.IsPending);
controller.Tick(0x30, 0x30, insideDungeon: true);
Assert.Empty(enqueued);
Assert.False(recenter.Advance());
failDungeonEnqueue = false;
Assert.True(recenter.Advance());
@ -1066,6 +1077,8 @@ public sealed class LandblockPresentationPipelineTests
Assert.True(state.IsNearTier(landblockId));
controller.ForceReloadWindow();
for (int frame = 0; frame < 16 && state.IsLoaded(landblockId); frame++)
controller.Tick(0x80, 0x80);
Assert.False(state.IsLoaded(landblockId));
Assert.Equal(1, presentationCalls);
@ -1276,6 +1289,25 @@ public sealed class LandblockPresentationPipelineTests
Assert.DoesNotContain(forbidden, field, StringComparison.OrdinalIgnoreCase);
}
private static bool Converge(
StreamingOriginRecenterCoordinator recenter,
StreamingController controller,
int observerX,
int observerY,
bool insideDungeon = false,
int maximumFrames = 64)
{
for (int frame = 0; frame < maximumFrames; frame++)
{
if (recenter.Advance())
return true;
controller.Tick(observerX, observerY, insideDungeon);
}
return recenter.Advance();
}
private static LandblockBuild BuildWithShell(
uint landblockId,
params WorldEntity[] entities)

View file

@ -51,6 +51,101 @@ public sealed class LandblockRetirementCoordinatorTests
Assert.Equal(1, calls[3]);
}
[Fact]
public void BudgetedRetirement_AdvancesAtMostTheAdmittedAtomicEntityWork()
{
const uint landblockId = 0x2021FFFFu;
WorldEntity[] entities =
[
Entity(1),
Entity(2),
Entity(3),
Entity(4),
];
var state = StateWith(landblockId, entities);
var lightingCalls = new Dictionary<uint, int>();
LandblockRetirementCoordinator coordinator =
LandblockRetirementCoordinator.CreateBudgeted(
state,
ticket => AdvancePresentationStep(
ticket,
entity => lightingCalls[entity.Id] =
lightingCalls.GetValueOrDefault(entity.Id) + 1),
ticket => CompletePresentation(
ticket,
lighting: entity => lightingCalls[entity.Id] =
lightingCalls.GetValueOrDefault(entity.Id) + 1));
coordinator.BeginFull(landblockId);
Assert.False(state.IsLoaded(landblockId));
Assert.Equal(1, coordinator.PendingCount);
int frames = 0;
while (coordinator.PendingCount != 0 && frames++ < 64)
{
var meter = new StreamingWorkMeter(Budget(maxEntityOperations: 2));
coordinator.Advance(meter);
meter.FinishFrame();
Assert.InRange(meter.Snapshot.Used.EntityOperations, 0, 2);
}
Assert.InRange(frames, 2, 64);
Assert.Equal(0, coordinator.PendingCount);
Assert.Equal(
entities.Select(entity => entity.Id).Order(),
lightingCalls.Keys.Order());
Assert.All(lightingCalls.Values, count => Assert.Equal(1, count));
}
[Fact]
public void BudgetedRetirement_FailedEntityRetriesWithoutReplayingCommittedPrefix()
{
const uint landblockId = 0x2022FFFFu;
var state = StateWith(
landblockId,
Entity(1),
Entity(2),
Entity(3));
var calls = new Dictionary<uint, int>();
bool failSecond = true;
LandblockRetirementCoordinator coordinator =
LandblockRetirementCoordinator.CreateBudgeted(
state,
ticket => AdvancePresentationStep(
ticket,
entity =>
{
calls[entity.Id] = calls.GetValueOrDefault(entity.Id) + 1;
if (entity.Id == 2 && failSecond)
{
failSecond = false;
throw new InvalidOperationException(
"injected metered entity failure");
}
}),
ticket => CompletePresentation(ticket));
coordinator.BeginFull(landblockId);
var firstMeter = new StreamingWorkMeter(Budget(maxEntityOperations: 64));
coordinator.Advance(firstMeter);
firstMeter.FinishFrame();
Assert.Equal(1, coordinator.PendingCount);
Assert.Equal(1, calls[1]);
Assert.Equal(1, calls[2]);
Assert.False(calls.ContainsKey(3));
Assert.Equal(1, firstMeter.Snapshot.FailureCount);
var retryMeter = new StreamingWorkMeter(Budget(maxEntityOperations: 64));
coordinator.Advance(retryMeter);
retryMeter.FinishFrame();
Assert.Equal(0, coordinator.PendingCount);
Assert.Equal(1, calls[1]);
Assert.Equal(2, calls[2]);
Assert.Equal(1, calls[3]);
}
[Fact]
public void ForceReload_RetiresEveryLoadedId_WhenOneOwnerRemainsPending()
{
@ -59,31 +154,88 @@ public sealed class LandblockRetirementCoordinatorTests
foreach (uint id in ids)
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
bool failOnce = true;
bool failRetirement = true;
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(
ticket,
terrain: () =>
{
if (ticket.LandblockId == ids[1] && failOnce)
if (ticket.LandblockId == ids[1] && failRetirement)
{
failOnce = false;
throw new InvalidOperationException("injected terrain failure");
}
}));
var controller = Controller(state, coordinator);
controller.ForceReloadWindow();
for (int frame = 0;
frame < 16 && ids.Any(state.IsLoaded);
frame++)
{
controller.Tick(0x20, 0x20);
}
foreach (uint id in ids)
Assert.False(state.IsLoaded(id));
Assert.Equal(1, coordinator.PendingCount);
failRetirement = false;
coordinator.Advance();
Assert.Equal(0, coordinator.PendingCount);
}
[Fact]
public void ForceReload_CapturesAndDetachesThroughTheSharedEntityBudget()
{
uint[] ids =
[
0x2323FFFFu,
0x2424FFFFu,
0x2525FFFFu,
];
var state = new GpuWorldState();
foreach (uint id in ids)
{
state.AddLandblock(new LoadedLandblock(
id,
new LandBlock(),
Array.Empty<WorldEntity>()));
}
var coordinator = new LandblockRetirementCoordinator(
state,
ticket => CompletePresentation(ticket));
var controller = Controller(
state,
coordinator,
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 1000,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 64 * StreamingWorkBudgetOptions.MiB,
MaxEntityOperations: 1,
MaxGpuUploadBytes: 64 * StreamingWorkBudgetOptions.MiB,
MaxGlRetireOperations: 64,
DestinationReserveFraction: 0.75f));
controller.ForceReloadWindow();
int priorLoaded = ids.Length;
for (int frame = 0; frame < 32 && priorLoaded != 0; frame++)
{
controller.Tick(0x23, 0x23);
int loaded = ids.Count(state.IsLoaded);
Assert.InRange(priorLoaded - loaded, 0, 1);
Assert.InRange(
controller.WorkDiagnostics.LastFrame.Used.EntityOperations,
0,
1);
priorLoaded = loaded;
}
Assert.Equal(0, priorLoaded);
Assert.Equal(ids.Length, controller.LastFullWindowRetirementLandblockCount);
}
[Fact]
public void ReplacementPublication_WaitsForSameIdRetirementFence()
{
@ -126,12 +278,18 @@ public sealed class LandblockRetirementCoordinatorTests
EmptyMesh(),
generation: 1));
controller.Tick(0x32, 0x32); // second failure; publication stays deferred
controller.Tick(0x32, 0x32); // first failure; publication stays at worker source
Assert.Equal(0, applyCount);
Assert.False(state.IsLoaded(landblockId));
Assert.True(coordinator.IsPending(landblockId));
controller.Tick(0x32, 0x32); // third attempt completes, then publishes once
controller.Tick(0x32, 0x32); // second failure
Assert.Equal(0, applyCount);
Assert.True(coordinator.IsPending(landblockId));
controller.Tick(0x32, 0x32); // third attempt completes the reload barrier
Assert.Equal(0, applyCount);
controller.Tick(0x32, 0x32); // replacement publishes on the next frame
Assert.Equal(1, applyCount);
Assert.True(state.IsLoaded(landblockId));
Assert.False(coordinator.IsPending(landblockId));
@ -338,7 +496,8 @@ public sealed class LandblockRetirementCoordinatorTests
GpuWorldState state,
LandblockRetirementCoordinator coordinator,
Func<int, IReadOnlyList<LandblockStreamResult>>? drain = null,
Action<LandblockBuild, LandblockMeshData>? apply = null) =>
Action<LandblockBuild, LandblockMeshData>? apply = null,
StreamingWorkBudgetOptions? workBudgetOptions = null) =>
new(
enqueueLoad: (_, _, _) => { },
enqueueUnload: (_, _) => { },
@ -347,7 +506,8 @@ public sealed class LandblockRetirementCoordinatorTests
state: state,
nearRadius: 0,
farRadius: 0,
retirementCoordinator: coordinator);
retirementCoordinator: coordinator,
workBudgetOptions: workBudgetOptions);
private static IReadOnlyList<LandblockStreamResult> Drain(
Queue<LandblockStreamResult> pending,
@ -359,6 +519,60 @@ public sealed class LandblockRetirementCoordinatorTests
return result;
}
private static LandblockRetirementOperationResult AdvancePresentationStep(
LandblockRetirementTicket ticket,
Action<WorldEntity>? lighting = null)
{
return ticket.NextIncompleteStage switch
{
LandblockRetirementStage.EntityLighting =>
ticket.RunEntityStep(
LandblockRetirementStage.EntityLighting,
static _ => true,
lighting ?? (_ => { })),
LandblockRetirementStage.EntityTranslucency =>
ticket.RunEntityStep(
LandblockRetirementStage.EntityTranslucency,
static _ => true,
static _ => { }),
LandblockRetirementStage.PluginProjection =>
ticket.RunEntityStep(
LandblockRetirementStage.PluginProjection,
static entity => entity.ServerGuid == 0,
static _ => { }),
LandblockRetirementStage.Terrain =>
ticket.RunOnceStep(
LandblockRetirementStage.Terrain,
static () => { }),
LandblockRetirementStage.Physics =>
ticket.RunOnceStep(
LandblockRetirementStage.Physics,
static () => { }),
LandblockRetirementStage.CellVisibility =>
ticket.RunOnceStep(
LandblockRetirementStage.CellVisibility,
static () => { }),
LandblockRetirementStage.BuildingRegistry =>
ticket.RunOnceStep(
LandblockRetirementStage.BuildingRegistry,
static () => { }),
LandblockRetirementStage.EnvironmentCells =>
ticket.RunOnceStep(
LandblockRetirementStage.EnvironmentCells,
static () => { }),
_ => LandblockRetirementOperationResult.NoWork,
};
}
private static StreamingWorkBudget Budget(int maxEntityOperations) => new(
maxUpdateTime: TimeSpan.FromSeconds(1),
maxCompletionAdmissions: 64,
maxAdoptedCpuBytes: 64 * 1024 * 1024,
maxEntityOperations,
maxGpuUploadBytes: 64 * 1024 * 1024,
maxGlRetireOperations: 64,
destinationReserveFraction: 0.75f);
private static void CompletePresentation(
LandblockRetirementTicket ticket,
Action<WorldEntity>? lighting = null,

View file

@ -275,7 +275,7 @@ public sealed class LocalPlayerTeleportControllerTests
}
[Fact]
public void SessionReset_ClearsTransitCancelsRevealAndRequiresRecenterConvergence()
public void SessionReset_ClearsTransitAndLetsRecenterConvergeAcrossLaterFrames()
{
var harness = new Harness();
harness.Controller.OnTeleportStarted(12);
@ -291,7 +291,9 @@ public sealed class LocalPlayerTeleportControllerTests
var failed = new Harness();
failed.Streaming.ResetResult = false;
Assert.Throws<InvalidOperationException>(() => failed.Controller.ResetSession());
failed.Controller.ResetSession();
Assert.False(failed.Controller.IsActive);
Assert.True(failed.Streaming.LastResetWasSessionEnding);
}
[Fact]

View file

@ -0,0 +1,123 @@
using System.Numerics;
using AcDream.App.Audio;
using AcDream.App.Streaming;
using AcDream.Core.Selection;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
public sealed class WorldGenerationQuiescenceTests
{
private const uint LandblockId = 0x1010FFFFu;
private const uint ServerGuid = 0x70000001u;
[Fact]
public void ActiveGeneration_HidesWorldQueriesButRetainsCanonicalOwners()
{
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
WorldEntity entity = Entity();
world.AddLandblock(new LoadedLandblock(
LandblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
world.PlaceLiveEntityProjection(LandblockId, entity);
world.SetLandblockAabb(LandblockId, Vector3.Zero, Vector3.One);
var nearby = new List<KeyValuePair<uint, WorldEntity>>();
Assert.True(world.IsLiveEntityVisible(ServerGuid));
Assert.Single(world.LandblockEntries);
Assert.Single(world.LandblockBounds);
availability.Begin(17);
world.CopyLiveEntitiesNearLandblock(LandblockId, 0, nearby);
Assert.False(availability.IsWorldAvailable);
Assert.False(world.IsLiveEntityVisible(ServerGuid));
Assert.Empty(world.LandblockEntries);
Assert.Empty(world.LandblockBounds);
Assert.Empty(nearby);
Assert.Same(entity, Assert.Single(world.Entities));
Assert.True(world.IsLoaded(LandblockId));
Assert.False(availability.End(16));
Assert.False(availability.IsWorldAvailable);
Assert.True(availability.End(17));
Assert.True(world.IsLiveEntityVisible(ServerGuid));
Assert.Same(entity, Assert.Single(world.LandblockEntries).Entities.Single());
}
[Fact]
public void SupersededReveal_StaysContinuouslySilentAndOnlyLatestGenerationReopens()
{
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
world.AddLandblock(new LoadedLandblock(
LandblockId,
new LandBlock(),
Array.Empty<WorldEntity>()));
world.PlaceLiveEntityProjection(LandblockId, Entity());
var selection = new SelectionState();
selection.Select(ServerGuid, SelectionChangeSource.World);
var audio = new RecordingAudioQuiescence();
var quiescence = new WorldGenerationQuiescence(
availability,
selection,
world,
audio);
quiescence.Begin(1);
quiescence.Begin(2);
quiescence.End(1);
Assert.False(availability.IsWorldAvailable);
Assert.Equal(2, availability.QuiescedGeneration);
Assert.Null(selection.SelectedObjectId);
Assert.Equal(1, audio.SuspendCalls);
Assert.Equal(0, audio.ResumeCalls);
quiescence.End(2);
Assert.True(availability.IsWorldAvailable);
Assert.Equal(1, audio.ResumeCalls);
}
[Fact]
public void Quiesce_PreservesNonWorldInventorySelection()
{
const uint inventoryGuid = 0x80000001u;
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
var selection = new SelectionState();
selection.Select(inventoryGuid, SelectionChangeSource.Inventory);
var quiescence = new WorldGenerationQuiescence(
availability,
selection,
world,
audio: null);
quiescence.Begin(3);
Assert.Equal(inventoryGuid, selection.SelectedObjectId);
}
private static WorldEntity Entity() => new()
{
Id = 1,
ServerGuid = ServerGuid,
SourceGfxObjOrSetupId = 0x02000001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
private sealed class RecordingAudioQuiescence : IWorldAudioQuiescence
{
public int SuspendCalls { get; private set; }
public int ResumeCalls { get; private set; }
public void SuspendWorldAudio() => SuspendCalls++;
public void ResumeWorldAudio() => ResumeCalls++;
}
}

View file

@ -1,4 +1,5 @@
using AcDream.App.Streaming;
using AcDream.Core.Selection;
namespace AcDream.App.Tests.Streaming;
@ -114,4 +115,46 @@ public sealed class WorldRevealCoordinatorTests
Assert.Equal(default, coordinator.Snapshot.Readiness);
Assert.Equal(0, coordinator.PortalMaterializationCount);
}
[Fact]
public void RevealLifetime_QuiescesContinuouslyAndOnlyActiveGenerationReopens()
{
var availability = new WorldGenerationAvailabilityState();
var world = new GpuWorldState(availability: availability);
var audio = new RecordingAudioQuiescence();
var quiescence = new WorldGenerationQuiescence(
availability,
new SelectionState(),
world,
audio);
var coordinator = new WorldRevealCoordinator(
isRenderNeighborhoodReady: (_, _) => true,
isSpawnCellReady: _ => true,
isTerrainNeighborhoodReady: (_, _) => true,
areCompositeTexturesReady: () => true,
prepareCompositeTextures: (_, _) => { },
invalidateCompositeTextures: () => { },
isSpawnClaimUnhydratable: _ => false,
quiescence: quiescence);
Assert.Equal(1, coordinator.Begin(WorldRevealKind.Login));
Assert.Equal(2, coordinator.Begin(WorldRevealKind.Portal));
Assert.False(availability.IsWorldAvailable);
Assert.Equal(2, availability.QuiescedGeneration);
Assert.Equal(1, audio.SuspendCalls);
coordinator.Complete();
Assert.True(availability.IsWorldAvailable);
Assert.Equal(1, audio.ResumeCalls);
}
private sealed class RecordingAudioQuiescence : AcDream.App.Audio.IWorldAudioQuiescence
{
public int SuspendCalls { get; private set; }
public int ResumeCalls { get; private set; }
public void SuspendWorldAudio() => SuspendCalls++;
public void ResumeWorldAudio() => ResumeCalls++;
}
}

View file

@ -38,6 +38,25 @@ public sealed class RecallTeleportAnimationTests
Assert.Equal(["objects", "network", "commands", "spatial"], calls);
}
[Fact]
public void LiveFrame_WhileCellsAreBlocked_DrainsNetworkAndCommandsWithoutWorldTicks()
{
var calls = new List<string>();
var availability = new WorldGenerationAvailabilityState();
availability.Begin(1);
var frame = new RetailLiveFrameCoordinator(
new TestLiveObjectFramePhase(_ => calls.Add("objects")),
new GpuWorldState(availability: availability),
new TestLiveSessionFramePhase(() => calls.Add("network")),
new TestPostNetworkCommandFramePhase(() => calls.Add("commands")),
new TestLiveSpatialReconcilePhase(() => calls.Add("spatial")),
availability);
frame.Tick(1f / 60f);
Assert.Equal(["network", "commands"], calls);
}
[Fact]
public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch()
{

View file

@ -1,5 +1,6 @@
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.Update;
using AcDream.App.World;
@ -129,6 +130,33 @@ public sealed class UpdateFrameOrchestratorTests
observed.Camera.Select(value => value.SimulationDeltaSeconds));
}
[Fact]
public void QuiescedWorld_FreezesScriptClockAndLivenessWhilePortalPhasesContinue()
{
var calls = new List<string>();
var observed = new FrameObservations();
var availability = new WorldGenerationAvailabilityState();
availability.Begin(7);
UpdateFrameOrchestrator frame = Create(
calls,
observed: observed,
availability: availability);
frame.Tick(new UpdateFrameInput(0.25));
frame.Tick(new UpdateFrameInput(0.25));
availability.End(7);
frame.Tick(new UpdateFrameInput(0.25));
Assert.Equal([0.0, 0.0, 0.25], observed.PublishedTimes);
Assert.Equal(1, calls.Count(call => call == "liveness"));
Assert.Equal(3, calls.Count(call => call == "streaming"));
Assert.Equal(3, calls.Count(call => call == "network"));
Assert.Equal(3, calls.Count(call => call == "teleport"));
Assert.Equal(3, calls.Count(call => call == "camera"));
Assert.Equal([0.25f, 0.25f, 0.25f], observed.LiveDeltas);
Assert.Equal([0.25f, 0.25f, 0.25f], observed.TeleportDeltas);
}
[Fact]
public void Clock_RejectsFiniteDeltaAboveTheSinglePrecisionConsumerRange()
{
@ -200,6 +228,7 @@ public sealed class UpdateFrameOrchestratorTests
typeof(ILocalPlayerTeleportFramePhase),
typeof(IPlayerModeAutoEntryFramePhase),
typeof(ICameraFramePhase),
typeof(IWorldGenerationAvailability),
];
FieldInfo[] fields = typeof(UpdateFrameOrchestrator).GetFields(
@ -534,7 +563,8 @@ public sealed class UpdateFrameOrchestratorTests
"new LiveEntityLivenessFramePhase(",
"session.LocalTeleport,",
"new PlayerModeAutoEntryFramePhase(",
"cameraFrame);");
"cameraFrame,",
"live.WorldAvailability);");
}
[Fact]
@ -860,7 +890,8 @@ public sealed class UpdateFrameOrchestratorTests
RecordingFailureSink? failureSink = null,
FrameObservations? observed = null,
bool teleportPlace = false,
bool inboundCreatedPlayer = false)
bool inboundCreatedPlayer = false,
IWorldGenerationAvailability? availability = null)
{
teardown ??= new RecordingTeardown(calls);
failureSink ??= new RecordingFailureSink();
@ -875,7 +906,8 @@ public sealed class UpdateFrameOrchestratorTests
new RecordingLiveness(calls),
new RecordingTeleport(calls, observed, teleportPlace),
new RecordingAutoEntry(calls),
new RecordingCamera(calls, observed, inboundCreatedPlayer));
new RecordingCamera(calls, observed, inboundCreatedPlayer),
availability);
}
private sealed class RecordingTeardown(List<string> calls)

View file

@ -543,7 +543,8 @@ public class StreamingControllerPriorityApplyTests
ctrl.ForceReloadWindow();
loads.Clear();
ctrl.Tick(11, 10); // overlap remains desired in the replacement window.
for (int i = 0; i < 32 && !loads.Any(load => load.Id == overlap); i++)
ctrl.Tick(11, 10);
ulong newGeneration = loads.Single(load => load.Id == overlap).Generation;
Assert.NotEqual(oldGeneration, newGeneration);
@ -551,7 +552,8 @@ public class StreamingControllerPriorityApplyTests
outbox.Enqueue(LoadedOf(overlap, newGeneration));
outbox.Enqueue(new LandblockStreamResult.Unloaded(overlap, oldGeneration));
ctrl.Tick(11, 10);
for (int i = 0; i < 8 && applied.Count == 0; i++)
ctrl.Tick(11, 10);
Assert.Equal(new[] { overlap }, applied);
Assert.True(state.IsLoaded(overlap));
@ -559,7 +561,7 @@ public class StreamingControllerPriorityApplyTests
}
[Fact]
public void HardRecenter_DropsStaleOutboxBeforeDeferredApplyBudget()
public void HardRecenter_DropsStaleOutboxThroughBoundedAdmissionBeforeCurrentResult()
{
uint current = StreamingRegion.EncodeLandblockIdForTest(11, 10);
var outbox = new Queue<LandblockStreamResult>();
@ -584,7 +586,8 @@ public class StreamingControllerPriorityApplyTests
ulong oldGeneration = loads[0].Generation;
ctrl.ForceReloadWindow();
loads.Clear();
ctrl.Tick(11, 10);
for (int i = 0; i < 32 && !loads.Any(load => load.Id == current); i++)
ctrl.Tick(11, 10);
ulong newGeneration = loads.Single(load => load.Id == current).Generation;
for (int i = 0; i < 12; i++)
@ -593,7 +596,8 @@ public class StreamingControllerPriorityApplyTests
oldGeneration));
outbox.Enqueue(LoadedOf(current, newGeneration));
ctrl.Tick(11, 10);
for (int i = 0; i < 32 && !applied.Contains(current); i++)
ctrl.Tick(11, 10);
Assert.Equal(new[] { current }, applied);
Assert.Equal(0, ctrl.DeferredApplyBacklog);