perf(streaming): cursor publication across frame budgets

This commit is contained in:
Erik 2026-07-24 19:10:18 +02:00
parent bb16f74fd4
commit 98f1ac8934
32 changed files with 2215 additions and 823 deletions

View file

@ -140,10 +140,12 @@ 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 unified
typed residency policy/accounting and bounded retained caches. Slice E0E3 now
typed residency policy/accounting and bounded retained caches. Slice E0E4 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
metered full-window/recenter retirement, and retained cursor-budgeted
render/physics/static/building/EnvCell publication; E5 destination reservation
and reveal-generation joining is next. The physical console is available and
reserved for E6 connected gates. Slices F/G
remain behind the explicit ECS decision gate and Slice J behind its separate
gameplay-owner gate. Evidence:
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md` and
@ -168,7 +170,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 E4+, and #225's
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's
lifestone/particle alpha visual gate.
Start structural work at `memory/project_gamewindow_decomposition.md` and

View file

@ -138,10 +138,12 @@ 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 unified
typed residency policy/accounting and bounded retained caches. Slice E0E3 now
typed residency policy/accounting and bounded retained caches. Slice E0E4 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
metered full-window/recenter retirement, and retained cursor-budgeted
render/physics/static/building/EnvCell publication; E5 destination reservation
and reveal-generation joining is next. The physical console is available and
reserved for E6 connected gates. Slices F/G
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
@ -166,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 E4+, and #225's
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice E5+, and #225's
lifestone/particle alpha visual gate.
Start structural work at `memory/project_gamewindow_decomposition.md` and

View file

@ -44,11 +44,12 @@ What does NOT go here:
smooth and physics, collision, and wire state remain correct.
- **Build hygiene:** `#228` records 17 clean-Release test-project warnings;
production compilation and all tests pass.
- **Performance side track:** Slices AD and Slice E0E3 are complete. Typed
- **Performance side track:** Slices AD and Slice E0E4 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:
budgeted full-window/recenter retirement are live. Publication now advances
from retained render/physics/static/building/EnvCell cursors without replay;
E5 destination reservation/reveal joining is next. The physical console is
available and remains reserved for the E6 connected gate. Evidence:
[`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

@ -14,13 +14,15 @@ banner cannot silently override the current program state.
[`plans/2026-07-23-world-interaction-completion.md`](plans/2026-07-23-world-interaction-completion.md).
The eight-slice `GameWindow` decomposition and its connected visual matrix
are complete.
- **Current active engineering program:** Slices AD and Slice E0E3 of
- **Current active engineering program:** Slices AD and Slice E0E4 of
[`plans/2026-07-24-modern-runtime-architecture.md`](plans/2026-07-24-modern-runtime-architecture.md)
are complete. Production world-mesh streaming consumes the validated
prepared package, typed residency is bounded/accounted, and streaming now
has typed admission plus immediate old-generation quiescence 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
has typed admission plus immediate old-generation quiescence, metered
retirement, and retained cursor-budgeted publication across render, physics,
statics, building registries, collision/reflood, and EnvCells. E5 destination
reservation/reveal joining is next. The physical console is available; its
connected performance and visual evidence remains the E6 gate. See
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`).
@ -32,7 +34,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,148 tests pass and
- **Latest automated baseline:** Release build succeeds; 8,152 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

@ -731,8 +731,18 @@ 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.
per-owner entity/stage cursors on the frame's one meter.
Accepted publication is likewise retained at its exact priority-queue head.
`LandblockPresentationPipeline` meters render, physics, static, building, and
EnvCell receipts one operation at a time; each owner captures stable ordered
inputs once and retries only its unfinished cursor. Building and EnvCell
replacements are constructed off-side and atomically replace their complete
owner snapshots. The final `GpuWorldState.MutationBatch` is intentionally one
observer-atomic boundary so bucket identity, render pins, activation, and the
outer visibility notice cannot be observed partially. Settings and native
callbacks may defer policy changes, but cannot bypass the frame meter to drain
pending publication.
### 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. **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-2 | Async readiness gates replace retail's synchronous destination cell load. **#229 refinement (2026-07-20):** login and F751 portal-space exit now share `WorldRevealReadinessBarrier`, so neither path can expose the normal viewport until the same render-publication, composite-texture, and collision domains converge. A hydratable indoor claim requires its owning Near-tier static/EnvCell mesh set, destination composites, and exact EnvCell physics (`IsSpawnCellReady`); an outdoor claim requires those render domains plus terrain/collision residency for the priority near ring. Hard-recenter generations and tier-aware completion application prevent stale overlapping loads/unloads or Far/Near jobs from opening or erasing the gate; mesh upload remains separate from balanced landblock ownership. Claims beyond NumCells still take the loud forced-placement path. `WorldRevealCoordinator` owns this shared lifetime; diagnostic telemetry observes its generation without defining another readiness path. **Slice E3 refinement (2026-07-24):** the same generation now publishes an immediate `WorldGenerationQuiescence` edge: old-world drawing/spatial queries, simulation/effect clocks, reconciliation, targeting, and 3-D audio stop while retained physical teardown advances through metered cursors and destination network/UI/streaming/readiness remain live. **Slice E4 refinement (2026-07-24):** accepted render/physics/static publication may span update frames through retained exact cursors, but reveal still consumes only the completed spatial/render-ready generation; building and EnvCell snapshots remain invisible until complete and the final spatial identity swap stays observer-atomic. The portal hold→materialize→regain-control lifecycle remains owned by `TeleportAnimSequencer`. | `src/AcDream.App/Streaming/WorldRevealCoordinator.cs`; `src/AcDream.App/Streaming/WorldGenerationQuiescence.cs`; `src/AcDream.App/Streaming/WorldRevealReadinessBarrier.cs`; `src/AcDream.App/Streaming/StreamingOriginRecenterCoordinator.cs`; `src/AcDream.App/Streaming/LandblockPresentationPipeline.cs`; `src/AcDream.App/Rendering/GameWindow.cs` (owner wiring + draw eligibility); `src/AcDream.App/Streaming/StreamingController.cs` (`IsRenderNeighborhoodResident`); `src/AcDream.App/Streaming/GpuWorldState.cs` (`IsRenderReady`); `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`IsSpawnCellReady`, `IsNeighborhoodTerrainResident`) | This is the asynchronous equivalent of retail leaving `SmartBox::position_update_complete` false while `CellManager::blocking_for_cells` is set: neither initial login nor portal arrival may reveal or continue simulating an old/partial collision world, a terrain-only Far shell, or a published-but-not-drawable GPU landblock. Indoor does not require a terrain heightmap, only the owning render landblock and exact EnvCell. | Gate opens early → grey/untextured first login or portal reveal, free-fall, wrong-cell rooting, missing scenery, or a still-active old generation; predicate never satisfies (streamer/DAT/upload failure) → login remains behind the world render gate, while portal transit reaches its existing loud timeout/forced-placement diagnostic. | `SmartBox::UseTime` 0x00455410; `gmSmartBoxUI::EndTeleportAnimation` 0x004D65A0 |
| AD-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

@ -1613,7 +1613,10 @@ port in any phase — no separate listing here.
> generation unavailable at the hard reveal edge and freezes its world
> simulation/audio while full-window and shared-origin teardown advance through
> metered resident, entity, and owner-stage cursors with exact retry receipts.
> E4 cursor-budgets publication next. F/G and J retain their explicit approval
> E4 now retains the exact accepted queue head and cursor-budgets render,
> physics, static, building-registry, EnvCell, collision, and reflood
> publication before one observer-atomic spatial swap. E5 destination
> reservation/reveal joining is next. F/G and J retain their explicit approval
> gates.
**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the

View file

@ -93,12 +93,14 @@ 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 E0E3 now define and enforce typed completion admission through explicit
Slice E0E4 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.
through exact metered retirement cursors. Render, physics, static, building,
and EnvCell publication now resume from retained exact cursors under the same
frame budget. E5 destination reservation and reveal-generation joining is
next. Slices F/G remain behind their explicit ECS approval gate, and Slice J
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 (E0E3 complete).
**Status:** Slices AD complete. Slice E is active (E0E4 complete).
**Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
@ -728,9 +728,10 @@ checkpoint retained staged or retiring bytes. Evidence:
**Checkpoint 2026-07-24:** E0 fixed the retail/adaptation contract and current
owner inventory. E1 landed the validated work profile, deterministic meter and
completion charge model, plus lifecycle-artifact shadow diagnostics while
preserving the prior scheduler's execution. E2 is the first enforcement
cutover.
completion charge model. E2 cut execution over to stable priority FIFOs. E3
added immediate generation quiescence and cursored retirement. E4 now advances
render/physics/static publication from retained exact receipts; E5 destination
reservation and reveal-generation joining is next.
**Purpose:** Remove update-thread portal transactions.

View file

@ -1,6 +1,6 @@
# Modern Runtime Slice E — Cost-budgeted streaming and retirement
**Status:** active — E0/E1/E2/E3 complete; E4 cursor-budgeted publication next
**Status:** active — E0/E1/E2/E3/E4 complete; E5 destination reservation 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
@ -325,6 +325,29 @@ valid GPU/FPS evidence.
- Feed GPU requested-byte and retirement-operation facts from renderer owners.
- Prove every failure resumes the exact unfinished operation without replay.
**Complete 2026-07-24.** One accepted completion now remains at the exact queue
head while `LandblockPresentationPipeline` advances retained render, physics,
and static receipts through the frame's single meter. Stable entity/Gfx/building
and prior-owner arrays are captured once. Terrain upload bytes are charged at
the render owner; physics cells/buildings, static collision/light/plugin work,
prior static teardown, shadow reflood, building-registry construction, and
EnvCell shell construction advance one bounded operation at a time. Building
and EnvCell replacements are prepared off-side and become visible only when
complete. Failures retain the unfinished cursor and never replay a committed
prefix. Settings/native callbacks can defer policy changes but cannot drain
publication outside `StreamingController.Tick`.
The final `GpuWorldState.MutationBatch` remains one deliberate observer-atomic
operation: bucket mutation, render-id ownership, activation, and the outer
visibility notification cannot span frames without exposing a partial spatial
identity. It is time-metered and named for E6 overrun evidence; all iterable
owner work now occurs before it.
Validation: focused render/building/publication/controller tests passed; the
complete App suite passed 3,669 / 3 skipped; the complete Release solution
passed 8,152 / 5 skipped; Release solution build green. Physical connected
performance and visual evidence remains an E6 gate.
### E5 — Destination reservation and reveal generation
- Replace eager `PriorityRadius` execution with destination reservations.

View file

@ -789,11 +789,11 @@ internal sealed class LivePresentationCompositionPhase
foundation.Terrain.RemoveLandblock,
d.CellVisibility,
worldState,
envCellLease.Resource.CommitLandblock,
build => EnvCellMeshPreparationScheduler.Schedule(
prepareEnvCells: build => EnvCellMeshPreparationScheduler.Schedule(
build,
foundation.MeshAdapter.MeshManager!),
envCellLease.Resource.RemoveLandblock);
removeEnvCells: envCellLease.Resource.RemoveLandblock,
envCellPublisher: envCellLease.Resource);
var landblockPhysicsPublisher = new LandblockPhysicsPublisher(
d.PhysicsEngine,
world.TerrainBuild.HeightTable);

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Wb;
@ -32,6 +33,30 @@ namespace AcDream.App.Rendering.Wb;
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
/// </summary>
internal sealed class BuildingRegistryPublication
{
internal BuildingRegistryPublication(
BuildingInfo[] buildings,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
{
Buildings = buildings;
LandblockId = landblockId;
CellsByCellId = cellsByCellId;
PreparationCommitted = buildings.Length == 0;
}
internal BuildingInfo[] Buildings { get; }
internal uint LandblockId { get; }
internal IReadOnlyDictionary<uint, LoadedCell> CellsByCellId { get; }
internal BuildingRegistry Registry { get; } = new();
internal List<(LoadedCell Cell, uint BuildingId)> CellStamps { get; } = new();
internal int BuildingCursor { get; set; }
internal uint NextBuildingId { get; set; } = 1;
internal bool PreparationCommitted { get; set; }
internal bool PublicationCommitted { get; set; }
}
public static class BuildingLoader
{
/// <summary>
@ -39,122 +64,167 @@ public static class BuildingLoader
/// Building IDs are allocated sequentially starting at 1 (0 is reserved for
/// "no building" semantics used by <c>LoadedCell.BuildingId</c> in RR4).
/// </summary>
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
/// the full cell id.</param>
/// <param name="cellsByCellId">Pre-loaded cells keyed by full 32-bit cell id.
/// Used for BFS extension (Step B) and exit-polygon collection (Step C).
/// An empty dict is valid for unit tests; Step B and C are skipped per cell.</param>
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
public static BuildingRegistry Build(
LandBlockInfo info,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
{
var reg = new BuildingRegistry();
if (info.Buildings is null || info.Buildings.Count == 0)
return reg;
uint lbMask = landblockId & 0xFFFF0000u;
uint nextId = 1;
foreach (var bInfo in info.Buildings)
BuildingRegistryPublication publication = PreparePublication(
info,
landblockId,
cellsByCellId);
while (!AdvancePreparationOne(publication))
{
var envCellIds = new HashSet<uint>();
var exitPortalPolys = new List<Vector3[]>();
}
CommitPublication(publication);
return publication.Registry;
}
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
// Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with
// the landblock prefix for the full id.
// Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in
// BuildingInfo — rare but WB guards against it too at PortalService.cs:58).
if (bInfo.Portals is not null)
internal static BuildingRegistryPublication PreparePublication(
LandBlockInfo info,
uint landblockId,
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
{
ArgumentNullException.ThrowIfNull(info);
ArgumentNullException.ThrowIfNull(cellsByCellId);
int buildingCount = info.Buildings?.Count ?? 0;
var buildings = new BuildingInfo[buildingCount];
for (int index = 0; index < buildingCount; index++)
buildings[index] = info.Buildings![index];
return new BuildingRegistryPublication(
buildings,
landblockId,
cellsByCellId);
}
internal static bool AdvancePreparationOne(
BuildingRegistryPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (publication.PreparationCommitted)
return true;
if (publication.BuildingCursor >= publication.Buildings.Length)
{
publication.PreparationCommitted = true;
return true;
}
AddBuilding(
publication,
publication.Buildings[publication.BuildingCursor]);
publication.BuildingCursor++;
if (publication.BuildingCursor >= publication.Buildings.Length)
publication.PreparationCommitted = true;
return publication.PreparationCommitted;
}
internal static void CommitPublication(
BuildingRegistryPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!publication.PreparationCommitted)
{
throw new InvalidOperationException(
"A building registry cannot publish before preparation completes.");
}
if (publication.PublicationCommitted)
return;
foreach ((LoadedCell cell, uint buildingId) in publication.CellStamps)
cell.BuildingId = buildingId;
publication.PublicationCommitted = true;
}
private static void AddBuilding(
BuildingRegistryPublication publication,
BuildingInfo bInfo)
{
uint lbMask = publication.LandblockId & 0xFFFF0000u;
var envCellIds = new HashSet<uint>();
var exitPortalPolys = new List<Vector3[]>();
// Step A: seed the cell set from BuildingInfo.Portals.
if (bInfo.Portals is not null)
{
foreach (var portal in bInfo.Portals)
{
foreach (var portal in bInfo.Portals)
{
if (portal.OtherCellId == 0xFFFF) continue;
envCellIds.Add(lbMask | portal.OtherCellId);
}
}
// Step B: BFS through interior CellPortals to find the full cell set.
// Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per
// BFS step). Mirrors WB PortalService.cs:67-79.
// When cellsByCellId is empty (unit-test path), BFS immediately exits.
var queue = new Queue<uint>(envCellIds);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!cellsByCellId.TryGetValue(current, out var cell)) continue;
foreach (var p in cell.Portals)
{
if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here
uint neighbourId = lbMask | p.OtherCellId;
if (envCellIds.Add(neighbourId))
queue.Enqueue(neighbourId);
}
}
// Step C: collect exit portal polygons in world space.
// For each interior cell, iterate its portals; for each exit portal
// (OtherCellId == 0xFFFF), transform the portal polygon vertices from
// cell-local space to world space via WorldTransform.
// Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path).
foreach (var cellId in envCellIds)
{
if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
for (int pi = 0; pi < cell.Portals.Count; pi++)
{
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
if (pi >= cell.PortalPolygons.Count) continue;
var localPoly = cell.PortalPolygons[pi];
if (localPoly.Length < 3) continue;
var worldPoly = new Vector3[localPoly.Length];
for (int v = 0; v < localPoly.Length; v++)
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
exitPortalPolys.Add(worldPoly);
}
}
bool hasPortalBounds = false;
var portalMin = new Vector3(float.MaxValue);
var portalMax = new Vector3(float.MinValue);
foreach (var poly in exitPortalPolys)
{
foreach (var v in poly)
{
hasPortalBounds = true;
portalMin = Vector3.Min(portalMin, v);
portalMax = Vector3.Max(portalMax, v);
}
}
// WB PortalService.cs:89: skip buildings with no interior cells.
if (envCellIds.Count == 0) continue;
var building = new Building
{
BuildingId = nextId++,
EnvCellIds = envCellIds,
ExitPortalPolygons = exitPortalPolys,
HasPortalBounds = hasPortalBounds,
PortalBounds = hasPortalBounds
? new WbBoundingBox(portalMin, portalMax)
: default,
};
reg.Add(building);
// Step 4: stamp BuildingId on each cell (Option C — both directions
// O(1)). The internal setter on LoadedCell.BuildingId is accessible
// because this class lives in the same assembly (AcDream.App).
foreach (var cellId in envCellIds)
{
if (cellsByCellId.TryGetValue(cellId, out var cell))
cell.BuildingId = building.BuildingId;
if (portal.OtherCellId == 0xFFFF) continue;
envCellIds.Add(lbMask | portal.OtherCellId);
}
}
return reg;
// Step B: BFS through interior portals.
var queue = new Queue<uint>(envCellIds);
while (queue.Count > 0)
{
uint current = queue.Dequeue();
if (!publication.CellsByCellId.TryGetValue(current, out var cell))
continue;
foreach (var portal in cell.Portals)
{
if (portal.OtherCellId == 0xFFFF) continue;
uint neighbourId = lbMask | portal.OtherCellId;
if (envCellIds.Add(neighbourId))
queue.Enqueue(neighbourId);
}
}
// Step C: collect exit portal polygons in world space.
foreach (uint cellId in envCellIds)
{
if (!publication.CellsByCellId.TryGetValue(cellId, out var cell))
continue;
for (int portalIndex = 0; portalIndex < cell.Portals.Count; portalIndex++)
{
if (cell.Portals[portalIndex].OtherCellId != 0xFFFF) continue;
if (portalIndex >= cell.PortalPolygons.Count) continue;
Vector3[] localPolygon = cell.PortalPolygons[portalIndex];
if (localPolygon.Length < 3) continue;
var worldPolygon = new Vector3[localPolygon.Length];
for (int vertexIndex = 0; vertexIndex < localPolygon.Length; vertexIndex++)
{
worldPolygon[vertexIndex] = Vector3.Transform(
localPolygon[vertexIndex],
cell.WorldTransform);
}
exitPortalPolys.Add(worldPolygon);
}
}
bool hasPortalBounds = false;
var portalMin = new Vector3(float.MaxValue);
var portalMax = new Vector3(float.MinValue);
foreach (Vector3[] polygon in exitPortalPolys)
{
foreach (Vector3 vertex in polygon)
{
hasPortalBounds = true;
portalMin = Vector3.Min(portalMin, vertex);
portalMax = Vector3.Max(portalMax, vertex);
}
}
if (envCellIds.Count == 0)
return;
uint buildingId = publication.NextBuildingId++;
publication.Registry.Add(new Building
{
BuildingId = buildingId,
EnvCellIds = envCellIds,
ExitPortalPolygons = exitPortalPolys,
HasPortalBounds = hasPortalBounds,
PortalBounds = hasPortalBounds
? new WbBoundingBox(portalMin, portalMax)
: default,
});
// Stamps remain off-side until the complete registry commits.
foreach (uint cellId in envCellIds)
{
if (publication.CellsByCellId.TryGetValue(cellId, out var cell))
publication.CellStamps.Add((cell, buildingId));
}
}
}

View file

@ -0,0 +1,45 @@
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Narrow update-thread publication seam used by streaming. Preparation builds
/// one private shell at a time; commit publishes the completed immutable
/// landblock snapshot with one owner dictionary replacement.
/// </summary>
public interface IEnvCellLandblockPublisher
{
EnvCellLandblockPublication PreparePublication(
EnvCellLandblockBuild build);
bool AdvancePreparationOne(EnvCellLandblockPublication publication);
void CommitPublication(EnvCellLandblockPublication publication);
}
public sealed class EnvCellLandblockPublication
{
internal EnvCellLandblockPublication(
object owner,
EnvCellLandblockBuild build)
{
Owner = owner;
Build = build;
Replacement = new EnvCellLandblock
{
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
};
TotalBounds = new WbBoundingBox(
new Vector3(float.MaxValue),
new Vector3(float.MinValue));
}
internal object Owner { get; }
internal EnvCellLandblockBuild Build { get; }
internal EnvCellLandblock Replacement { get; }
internal WbBoundingBox TotalBounds { get; set; }
internal int ShellCursor { get; set; }
internal bool PreparationCommitted { get; set; }
internal bool PublicationCommitted { get; set; }
}

View file

@ -29,8 +29,11 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
public sealed unsafe class EnvCellRenderer : IDisposable
public sealed unsafe class EnvCellRenderer :
IDisposable,
IEnvCellLandblockPublisher
{
private readonly object _publicationOwner = new();
private readonly GL _gl;
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
@ -381,8 +384,80 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// </summary>
public void CommitLandblock(EnvCellLandblockBuild build)
{
_landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
EnvCellLandblockPublication publication = PreparePublication(build);
while (!AdvancePreparationOne(publication))
{
}
CommitPublication(publication);
}
EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication(
EnvCellLandblockBuild build) =>
PreparePublication(build);
bool IEnvCellLandblockPublisher.AdvancePreparationOne(
EnvCellLandblockPublication publication) =>
AdvancePreparationOne(publication);
void IEnvCellLandblockPublisher.CommitPublication(
EnvCellLandblockPublication publication) =>
CommitPublication(publication);
internal EnvCellLandblockPublication PreparePublication(
EnvCellLandblockBuild build)
{
ArgumentNullException.ThrowIfNull(build);
return new EnvCellLandblockPublication(_publicationOwner, build);
}
internal bool AdvancePreparationOne(
EnvCellLandblockPublication publication)
{
ValidatePublication(publication);
if (publication.PreparationCommitted)
return true;
if (publication.ShellCursor < publication.Build.Shells.Length)
{
AddShell(
publication.Replacement,
publication.Build.Shells[publication.ShellCursor]);
WbBoundingBox bounds =
publication.Build.Shells[publication.ShellCursor].WorldBounds;
publication.TotalBounds =
publication.ShellCursor == 0
? bounds
: WbBoundingBox.Union(
publication.TotalBounds,
bounds);
publication.ShellCursor++;
return false;
}
publication.Replacement.TotalEnvCellBounds =
publication.Replacement.EnvCellBounds.Count == 0
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
: publication.TotalBounds;
publication.Replacement.InstancesReady = true;
publication.Replacement.MeshDataReady = true;
publication.Replacement.GpuReady = true;
publication.PreparationCommitted = true;
return true;
}
internal void CommitPublication(
EnvCellLandblockPublication publication)
{
ValidatePublication(publication);
if (!publication.PreparationCommitted)
throw new InvalidOperationException(
"EnvCell landblock publication cannot commit before preparation.");
if (publication.PublicationCommitted)
return;
_landblocks[publication.Build.LandblockId] = publication.Replacement;
NeedsPrepare = true;
publication.PublicationCommitted = true;
}
/// <summary>
@ -398,35 +473,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
};
foreach (var shell in build.Shells)
{
replacement.Instances.Add(new EnvCellSceneryInstance
{
ObjectId = shell.GeometryId,
InstanceId = shell.CellId,
IsBuilding = true,
IsEntryCell = false,
WorldPosition = shell.WorldPosition,
LocalPosition = Vector3.Zero,
Rotation = shell.Rotation,
Scale = Vector3.One,
Transform = shell.Transform,
LocalBoundingBox = shell.LocalBounds,
BoundingBox = shell.WorldBounds,
});
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
{
instances = new List<InstanceData>();
replacement.BuildingPartGroups[shell.GeometryId] = instances;
}
instances.Add(new InstanceData
{
Transform = shell.Transform,
CellId = shell.CellId,
Flags = 0,
});
}
AddShell(replacement, shell);
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
foreach (var bounds in replacement.EnvCellBounds.Values)
@ -441,6 +488,53 @@ public sealed unsafe class EnvCellRenderer : IDisposable
return replacement;
}
private static void AddShell(
EnvCellLandblock replacement,
EnvCellShellPlacement shell)
{
replacement.Instances.Add(new EnvCellSceneryInstance
{
ObjectId = shell.GeometryId,
InstanceId = shell.CellId,
IsBuilding = true,
IsEntryCell = false,
WorldPosition = shell.WorldPosition,
LocalPosition = Vector3.Zero,
Rotation = shell.Rotation,
Scale = Vector3.One,
Transform = shell.Transform,
LocalBoundingBox = shell.LocalBounds,
BoundingBox = shell.WorldBounds,
});
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
if (!replacement.BuildingPartGroups.TryGetValue(
shell.GeometryId,
out var instances))
{
instances = new List<InstanceData>();
replacement.BuildingPartGroups[shell.GeometryId] = instances;
}
instances.Add(new InstanceData
{
Transform = shell.Transform,
CellId = shell.CellId,
Flags = 0,
});
}
private void ValidatePublication(
EnvCellLandblockPublication publication)
{
ArgumentNullException.ThrowIfNull(publication);
if (!ReferenceEquals(publication.Owner, _publicationOwner))
{
throw new ArgumentException(
"The EnvCell publication receipt belongs to another renderer.",
nameof(publication));
}
}
/// <summary>
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
/// </summary>

View file

@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter.Types;
namespace AcDream.App.Streaming;
@ -15,15 +16,45 @@ public sealed class LandblockPhysicsPublication
internal LandblockPhysicsPublication(
object owner,
LandblockBuild build,
Vector3 origin)
Vector3 origin,
uint currentCellId,
BuildingInfo[] buildings,
uint[] priorStaticOwnerIds)
{
Owner = owner;
Build = build;
Origin = origin;
CurrentCellId = currentCellId;
Buildings = buildings;
PriorStaticOwnerIds = priorStaticOwnerIds;
}
internal object Owner { get; }
internal LandblockBuild Build { get; }
internal uint CurrentCellId { get; }
internal BuildingInfo[] Buildings { get; }
internal uint[] PriorStaticOwnerIds { get; }
internal SortedSet<uint> GfxObjectIdSet { get; } = new();
internal uint[] GfxObjectIds { get; set; } = Array.Empty<uint>();
internal int PreparationCursor { get; set; }
internal bool PreparationCommitted { get; set; }
internal bool PriorCacheRemoved { get; set; }
internal TerrainSurface? TerrainSurface { get; set; }
internal List<CellSurface> CellSurfaces { get; } = new();
internal List<PortalPlane> PortalPlanes { get; } = new();
internal uint CellCursor { get; set; }
internal int BuildingCursor { get; set; }
internal bool BaseCommitted { get; set; }
internal int GfxCursor { get; set; }
internal int PriorStaticCursor { get; set; }
internal int StaticCursor { get; set; }
internal int BspOwnerCount { get; set; }
internal int CylinderOwnerCount { get; set; }
internal int NoCollisionCount { get; set; }
internal int SceneryTried { get; set; }
internal uint[]? RefloodOwnerIds { get; set; }
internal int RefloodCursor { get; set; }
internal bool RefloodCommitted { get; set; }
internal bool BeginCommitted { get; set; }
internal bool CompletionCommitted { get; set; }
@ -142,6 +173,21 @@ public sealed class LandblockPhysicsPublisher
/// </summary>
public LandblockPhysicsPublication PreparePublication(
LandblockRenderPublication renderPublication)
{
LandblockPhysicsPublication publication =
CreatePublication(renderPublication);
while (!AdvancePreparationOne(publication))
{
}
return publication;
}
/// <summary>
/// Captures the immutable physics root without walking the entity suffix.
/// The concrete pipeline advances that walk under its frame meter.
/// </summary>
internal LandblockPhysicsPublication CreatePublication(
LandblockRenderPublication renderPublication)
{
ArgumentNullException.ThrowIfNull(renderPublication);
LandblockBuild build = renderPublication.Build;
@ -149,10 +195,50 @@ public sealed class LandblockPhysicsPublisher
if (!IsFinite(origin))
throw new ArgumentOutOfRangeException(nameof(renderPublication));
PhysicsDatBundle datBundle =
build.Landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
BuildingInfo[] buildings = datBundle.Info?.Buildings.ToArray()
?? Array.Empty<BuildingInfo>();
return new LandblockPhysicsPublication(
_receiptOwner,
build,
origin);
origin,
_physicsDataCache.CellGraph.CurrCell?.Id ?? 0u,
buildings,
_physicsEngine.ShadowObjects.CaptureStaticOwnersForLandblock(
build.Landblock.LandblockId));
}
/// <summary>
/// Advances one entity's stable GfxObj-index preparation. No external
/// physics state is mutated until this cursor and the static-validation
/// cursor both complete.
/// </summary>
internal bool AdvancePreparationOne(
LandblockPhysicsPublication publication)
{
ValidateReceipt(publication);
if (publication.PreparationCommitted)
return true;
IReadOnlyList<WorldEntity> entities =
publication.Build.Landblock.Entities;
if (publication.PreparationCursor < entities.Count)
{
WorldEntity entity = entities[publication.PreparationCursor];
for (int i = 0; i < entity.MeshRefs.Count; i++)
{
uint id = entity.MeshRefs[i].GfxObjId;
if ((id & 0xFF000000u) == 0x01000000u)
publication.GfxObjectIdSet.Add(id);
}
publication.PreparationCursor++;
return false;
}
publication.GfxObjectIds = publication.GfxObjectIdSet.ToArray();
publication.PreparationCommitted = true;
return true;
}
/// <summary>
@ -160,10 +246,26 @@ public sealed class LandblockPhysicsPublisher
/// retained receipt.
/// </summary>
public void BeginPublication(LandblockPhysicsPublication publication)
{
ValidateReceipt(publication);
if (!publication.PreparationCommitted)
throw new InvalidOperationException(
"Physics publication cannot begin before preparation commits.");
while (!AdvanceBeginOne(publication))
{
}
}
/// <summary>
/// Advances one exact physics-prefix operation: prior-cache withdrawal,
/// terrain construction, one EnvCell, one building, or the final canonical
/// physics-engine replacement. All arrays and cursors live on the receipt.
/// </summary>
internal bool AdvanceBeginOne(LandblockPhysicsPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return;
return true;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
@ -171,157 +273,76 @@ public sealed class LandblockPhysicsPublisher
LoadedLandblock landblock = build.Landblock;
PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
uint currentCellId = _physicsDataCache.CellGraph.CurrCell?.Id ?? 0u;
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
// A publication is an exact replacement for this landblock prefix.
// CacheCellStruct/CacheBuilding intentionally use retail first-wins
// semantics within one pass, so retire the prior pass before adding
// the replacement. Adjacent prefixes remain untouched.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId);
// TerrainInfo.Type occupies bits 2-6. The low byte retains those bits
// plus Road (bits 0-1), which TerrainSurface masks independently.
var terrainBytes = new byte[81];
for (int i = 0; i < terrainBytes.Length; i++)
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
var terrainSurface = new TerrainSurface(
landblock.Heightmap.Height,
_heightTable,
landblockX,
landblockY,
terrainBytes);
var cellSurfaces = new List<CellSurface>();
var portalPlanes = new List<PortalPlane>();
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo = datBundle.Info;
if (landblockInfo is not null && landblockInfo.NumCells > 0)
if (!publication.PriorCacheRemoved)
{
uint firstCellId =
(landblock.LandblockId & 0xFFFF0000u) | 0x0100u;
for (uint offset = 0; offset < landblockInfo.NumCells; offset++)
// CacheCellStruct/CacheBuilding use first-wins semantics within one
// publication, so the replacement pass starts with one exact
// landblock-scoped withdrawal.
_physicsDataCache.RemoveCellsForLandblock(landblock.LandblockId);
_physicsDataCache.RemoveBuildingsForLandblock(landblock.LandblockId);
_physicsDataCache.CellGraph.RemoveEnvCellsForLandblock(
landblock.LandblockId);
publication.PriorCacheRemoved = true;
}
else if (publication.TerrainSurface is null)
{
uint landblockX = (landblock.LandblockId >> 24) & 0xFFu;
uint landblockY = (landblock.LandblockId >> 16) & 0xFFu;
var terrainBytes = new byte[81];
for (int i = 0; i < terrainBytes.Length; i++)
terrainBytes[i] = (byte)(ushort)landblock.Heightmap.Terrain[i];
publication.TerrainSurface = new TerrainSurface(
landblock.Heightmap.Height,
_heightTable,
landblockX,
landblockY,
terrainBytes);
}
else if (landblockInfo is not null
&& publication.CellCursor < landblockInfo.NumCells)
{
PublishCell(publication, datBundle, publication.CellCursor);
publication.CellCursor++;
}
else if (publication.BuildingCursor < publication.Buildings.Length)
{
PublishBuilding(
landblock,
datBundle,
publication.TerrainSurface,
origin,
publication.Buildings[publication.BuildingCursor]);
publication.BuildingCursor++;
}
else if (!publication.BaseCommitted)
{
_physicsEngine.AddLandblock(
landblock.LandblockId,
publication.TerrainSurface,
publication.CellSurfaces,
publication.PortalPlanes,
origin.X,
origin.Y);
if ((publication.CurrentCellId & 0xFFFF0000u)
== (landblock.LandblockId & 0xFFFF0000u))
{
uint envCellId = firstCellId + offset;
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell))
continue;
if (envCell.EnvironmentId == 0)
continue;
if (!datBundle.Environments.TryGetValue(
0x0D000000u | envCell.EnvironmentId,
out var environment))
{
continue;
}
if (!environment.Cells.TryGetValue(
envCell.CellStructure,
out var cellStruct))
{
continue;
}
Quaternion rotation = envCell.Position.Orientation;
Vector3 cellOriginWorld = envCell.Position.Origin + origin;
Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
var worldVertices = new Dictionary<ushort, Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach ((ushort vertexId, var vertex) in
cellStruct.VertexArray.Vertices)
{
Vector3 worldPosition =
Vector3.Transform(vertex.Origin, rotation)
+ cellOriginWorld;
worldVertices[(ushort)vertexId] = worldPosition;
}
var polygonVertexIds = new List<List<short>>(
cellStruct.PhysicsPolygons.Count);
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
{
var vertexIds = new List<short>(polygon.VertexIds.Count);
foreach (short vertexId in polygon.VertexIds)
vertexIds.Add(vertexId);
polygonVertexIds.Add(vertexIds);
}
cellSurfaces.Add(new CellSurface(
envCellId,
worldVertices,
polygonVertexIds));
// CellPortal.PolygonId indexes rendering Polygons, not
// PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(
portal.PolygonId,
out var polygon)
|| polygon.VertexIds.Count < 3)
{
continue;
}
var portalVertices = new Vector3[polygon.VertexIds.Count];
bool allFound = true;
for (int index = 0; index < polygon.VertexIds.Count; index++)
{
if (!worldVertices.TryGetValue(
(ushort)polygon.VertexIds[index],
out portalVertices[index]))
{
allFound = false;
break;
}
}
if (!allFound)
continue;
portalPlanes.Add(PortalPlane.FromVertices(
portalVertices.AsSpan(),
portal.OtherCellId,
envCellId & 0xFFFFu,
(ushort)portal.Flags));
}
_physicsEngine.UpdatePlayerCurrCell(publication.CurrentCellId);
}
publication.BaseCommitted = true;
}
int buildingCount = PublishBuildings(
landblock,
datBundle,
landblockInfo,
terrainSurface,
origin);
_physicsEngine.AddLandblock(
landblock.LandblockId,
terrainSurface,
cellSurfaces,
portalPlanes,
origin.X,
origin.Y);
if ((currentCellId & 0xFFFF0000u)
== (landblock.LandblockId & 0xFFFF0000u))
else
{
_physicsEngine.UpdatePlayerCurrCell(currentCellId);
_cellSurfaceCount += publication.CellSurfaces.Count;
_portalPlaneCount += publication.PortalPlanes.Count;
_buildingCount += publication.Buildings.Length;
publication.BeginCommitted = true;
_beginCount++;
}
_cellSurfaceCount += cellSurfaces.Count;
_portalPlaneCount += portalPlanes.Count;
_buildingCount += buildingCount;
publication.BeginCommitted = true;
_beginCount++;
_basePublishTicks += Stopwatch.GetTimestamp() - started;
return publication.BeginCommitted;
}
/// <summary>
@ -338,209 +359,93 @@ public sealed class LandblockPhysicsPublisher
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Physics publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return;
while (!AdvanceCompleteOne(publication, beforeStaticCollision))
{
}
}
long completedStarted = Stopwatch.GetTimestamp();
/// <summary>
/// Advances one exact physics-suffix operation: one GfxObj cache entry,
/// prior static withdrawal, one static light/collision entity, or reflood.
/// </summary>
internal bool AdvanceCompleteOne(
LandblockPhysicsPublication publication,
Action<WorldEntity>? beforeStaticCollision = null)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Physics publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return true;
long started = Stopwatch.GetTimestamp();
LoadedLandblock landblock = publication.Build.Landblock;
PhysicsDatBundle datBundle =
landblock.PhysicsDats ?? PhysicsDatBundle.Empty;
long cacheStarted = Stopwatch.GetTimestamp();
foreach (WorldEntity entity in landblock.Entities)
if (publication.GfxCursor < publication.GfxObjectIds.Length)
{
foreach (MeshRef meshRef in entity.MeshRefs)
{
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u)
continue;
if (datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx))
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
long cacheStarted = Stopwatch.GetTimestamp();
uint gfxObjectId = publication.GfxObjectIds[publication.GfxCursor];
if (datBundle.GfxObjs.TryGetValue(gfxObjectId, out var gfx))
_physicsDataCache.CacheGfxObj(gfxObjectId, gfx);
publication.GfxCursor++;
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
}
_gfxCacheTicks += Stopwatch.GetTimestamp() - cacheStarted;
// The immutable entity list is an exact Near snapshot. Retire every
// static owner created by the prior snapshot before registering this
// one; RegisterMultiPart can replace matching IDs but cannot discover
// owners omitted by a ForceReload/reapply. Logical-owner removal also
// clears footprints flooded across an adjacent landblock seam.
_physicsEngine.ShadowObjects.DeregisterStaticOwnersForLandblock(
landblock.LandblockId);
int landblockBspCount = 0;
int landblockCylinderCount = 0;
int landblockNoCollisionCount = 0;
int sceneryTried = 0;
foreach (WorldEntity entity in landblock.Entities)
else if (publication.PriorStaticCursor
< publication.PriorStaticOwnerIds.Length)
{
_physicsEngine.ShadowObjects.DeregisterStaticOwnerForLandblock(
publication.PriorStaticOwnerIds[
publication.PriorStaticCursor],
landblock.LandblockId);
publication.PriorStaticCursor++;
}
else if (publication.StaticCursor < landblock.Entities.Count)
{
WorldEntity entity = landblock.Entities[publication.StaticCursor];
beforeStaticCollision?.Invoke(entity);
// Retail building shells collide exclusively through the
// per-landcell building channel. They never become ordinary
// shadow objects, even when their source Setup has cylinders.
if (entity.IsBuildingShell)
continue;
int entityBspCount = 0;
int entityCylinderCount = 0;
uint sourcePrefix =
entity.SourceGfxObjOrSetupId & 0xFF000000u;
bool isOutdoorMesh =
(entity.Id & 0x80000000u) != 0
|| (entity.Id < 0x40000000u
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u));
if (isOutdoorMesh)
sceneryTried++;
IReadOnlyList<ShadowShape> bspShapes =
ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs,
entity.IsBuildingShell,
_physicsDataCache.GetGfxObj);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
bspShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogMultipartRegistration(landblock, entity, bspShapes);
}
SetupPhysics? setup =
_physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setup is not null && entityBspCount == 0)
{
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var setupShapes = new List<ShadowShape>();
for (int cylinderIndex = 0;
cylinderIndex < setup.CylSpheres.Count;
cylinderIndex++)
{
DatReaderWriter.Types.CylSphere cylinder =
setup.CylSpheres[cylinderIndex];
float radius = cylinder.Radius * scale;
float baseHeight = cylinder.Height > 0f
? cylinder.Height
: cylinder.Radius * 4f;
float height = baseHeight * scale;
if (radius <= 0f)
continue;
Vector3 localOffset = cylinder.Origin * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setup.CylSpheres.Count == 0)
{
for (int sphereIndex = 0;
sphereIndex < setup.Spheres.Count;
sphereIndex++)
{
DatReaderWriter.Types.Sphere sphere =
setup.Spheres[sphereIndex];
if (sphere.Radius <= 0f)
continue;
float radius = sphere.Radius * scale;
Vector3 localOffset = sphere.Origin * scale;
Vector3 localBaseOffset = localOffset
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localBaseOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: radius * 2f));
}
}
if (setup.CylSpheres.Count == 0
&& setup.Spheres.Count == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setupShapes.Count > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
setupShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogSetupRegistration(landblock, entity, setupShapes);
entityCylinderCount = setupShapes.Count;
}
}
if (entityBspCount > 0)
landblockBspCount++;
if (entityCylinderCount > 0)
landblockCylinderCount++;
if (entityBspCount == 0 && entityCylinderCount == 0
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u))
{
landblockNoCollisionCount++;
}
PublishStaticEntity(publication, entity);
publication.StaticCursor++;
}
if (PhysicsDiagnostics.ProbeBuildingEnabled && sceneryTried > 0)
else if (publication.RefloodOwnerIds is null)
{
Console.WriteLine(
$"lb 0x{landblock.LandblockId:X8}: scenery tried={sceneryTried} " +
$"(outdoorNone={landblockNoCollisionCount})");
publication.RefloodOwnerIds =
_physicsEngine.ShadowObjects
.CaptureRefloodOwnersForLandblock(landblock.LandblockId);
}
else if (publication.RefloodCursor
< publication.RefloodOwnerIds.Length)
{
_physicsEngine.ShadowObjects.RefloodOwnerForLandblock(
publication.RefloodOwnerIds[publication.RefloodCursor],
landblock.LandblockId);
publication.RefloodCursor++;
}
else if (!publication.RefloodCommitted)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled
&& publication.SceneryTried > 0)
{
Console.WriteLine(
$"lb 0x{landblock.LandblockId:X8}: scenery tried={publication.SceneryTried} " +
$"(outdoorNone={publication.NoCollisionCount})");
}
LogMissingSceneryBounds(landblock);
_refloodCount++;
publication.RefloodCommitted = true;
}
else
{
_staticBspOwnerCount += publication.BspOwnerCount;
_staticCylinderOwnerCount += publication.CylinderOwnerCount;
publication.CompletionCommitted = true;
_completeCount++;
}
LogMissingSceneryBounds(landblock);
_physicsEngine.ShadowObjects.RefloodLandblock(landblock.LandblockId);
_refloodCount++;
_staticBspOwnerCount += landblockBspCount;
_staticCylinderOwnerCount += landblockCylinderCount;
publication.CompletionCommitted = true;
_completeCount++;
_completePublishTicks +=
Stopwatch.GetTimestamp() - completedStarted;
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return publication.CompletionCommitted;
}
public void DemoteToTerrain(uint landblockId)
@ -555,54 +460,292 @@ public sealed class LandblockPhysicsPublisher
_fullRemovalCount++;
}
private int PublishBuildings(
private void PublishCell(
LandblockPhysicsPublication publication,
PhysicsDatBundle datBundle,
uint offset)
{
LoadedLandblock landblock = publication.Build.Landblock;
uint envCellId =
(landblock.LandblockId & 0xFFFF0000u) | (0x0100u + offset);
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)
|| envCell.EnvironmentId == 0
|| !datBundle.Environments.TryGetValue(
0x0D000000u | envCell.EnvironmentId,
out var environment)
|| !environment.Cells.TryGetValue(
envCell.CellStructure,
out var cellStruct))
{
return;
}
Quaternion rotation = envCell.Position.Orientation;
Vector3 cellOriginWorld = envCell.Position.Origin + publication.Origin;
Matrix4x4 physicsCellTransform =
Matrix4x4.CreateFromQuaternion(rotation)
* Matrix4x4.CreateTranslation(cellOriginWorld);
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
var worldVertices = new Dictionary<ushort, Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach ((ushort vertexId, var vertex) in
cellStruct.VertexArray.Vertices)
{
Vector3 worldPosition =
Vector3.Transform(vertex.Origin, rotation)
+ cellOriginWorld;
worldVertices[(ushort)vertexId] = worldPosition;
}
var polygonVertexIds = new List<List<short>>(
cellStruct.PhysicsPolygons.Count);
foreach (var polygon in cellStruct.PhysicsPolygons.Values)
{
var vertexIds = new List<short>(polygon.VertexIds.Count);
foreach (short vertexId in polygon.VertexIds)
vertexIds.Add(vertexId);
polygonVertexIds.Add(vertexIds);
}
var portalPlanes = new List<PortalPlane>();
// CellPortal.PolygonId indexes rendering Polygons, not
// PhysicsPolygons (ACViewer EnvCell.find_transit_cells).
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(
portal.PolygonId,
out var polygon)
|| polygon.VertexIds.Count < 3)
{
continue;
}
var portalVertices = new Vector3[polygon.VertexIds.Count];
bool allFound = true;
for (int index = 0; index < polygon.VertexIds.Count; index++)
{
if (!worldVertices.TryGetValue(
(ushort)polygon.VertexIds[index],
out portalVertices[index]))
{
allFound = false;
break;
}
}
if (!allFound)
continue;
portalPlanes.Add(PortalPlane.FromVertices(
portalVertices.AsSpan(),
portal.OtherCellId,
envCellId & 0xFFFFu,
(ushort)portal.Flags));
}
publication.CellSurfaces.Add(new CellSurface(
envCellId,
worldVertices,
polygonVertexIds));
publication.PortalPlanes.AddRange(portalPlanes);
}
private void PublishBuilding(
LoadedLandblock landblock,
PhysicsDatBundle datBundle,
DatReaderWriter.DBObjs.LandBlockInfo? landblockInfo,
TerrainSurface terrainSurface,
Vector3 origin)
Vector3 origin,
BuildingInfo building)
{
if (landblockInfo is null || landblockInfo.Buildings.Count == 0)
return 0;
uint landblockPrefix = landblock.LandblockId & 0xFFFF0000u;
foreach (var building in landblockInfo.Buildings)
var portals = new List<BldPortalInfo>(building.Portals.Count);
foreach (var portal in building.Portals)
{
var portals = new List<BldPortalInfo>(building.Portals.Count);
foreach (var portal in building.Portals)
{
portals.Add(new BldPortalInfo(
otherCellId: landblockPrefix | (uint)portal.OtherCellId,
otherPortalId: unchecked((short)portal.OtherPortalId),
flags: (ushort)portal.Flags));
}
Vector3 buildingOrigin = building.Frame.Origin + origin;
Matrix4x4 buildingTransform =
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* Matrix4x4.CreateTranslation(buildingOrigin);
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
building.Frame.Origin.X,
building.Frame.Origin.Y);
uint landcellId = landblockPrefix | landcellLow;
uint shellPartZero = building.ModelId;
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
{
datBundle.Setups.TryGetValue(
building.ModelId,
out var setup);
shellPartZero = setup is not null && setup.Parts.Count > 0
? setup.Parts[0]
: 0u;
}
_physicsDataCache.CacheBuilding(
landcellId,
portals,
buildingTransform,
modelId: shellPartZero);
portals.Add(new BldPortalInfo(
otherCellId: landblockPrefix | (uint)portal.OtherCellId,
otherPortalId: unchecked((short)portal.OtherPortalId),
flags: (ushort)portal.Flags));
}
Vector3 buildingOrigin = building.Frame.Origin + origin;
Matrix4x4 buildingTransform =
Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
* Matrix4x4.CreateTranslation(buildingOrigin);
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
building.Frame.Origin.X,
building.Frame.Origin.Y);
uint landcellId = landblockPrefix | landcellLow;
uint shellPartZero = building.ModelId;
if ((shellPartZero & 0xFF000000u) == 0x02000000u)
{
datBundle.Setups.TryGetValue(
building.ModelId,
out var setup);
shellPartZero = setup is not null && setup.Parts.Count > 0
? setup.Parts[0]
: 0u;
}
_physicsDataCache.CacheBuilding(
landcellId,
portals,
buildingTransform,
modelId: shellPartZero);
}
private void PublishStaticEntity(
LandblockPhysicsPublication publication,
WorldEntity entity)
{
LoadedLandblock landblock = publication.Build.Landblock;
// Retail building shells collide exclusively through the per-landcell
// building channel. They never become ordinary shadow objects.
if (entity.IsBuildingShell)
return;
int entityBspCount = 0;
int entityCylinderCount = 0;
uint sourcePrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
bool isOutdoorMesh =
(entity.Id & 0x80000000u) != 0
|| (entity.Id < 0x40000000u
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u));
if (isOutdoorMesh)
publication.SceneryTried++;
IReadOnlyList<ShadowShape> bspShapes =
ShadowShapeBuilder.FromLandblockBspParts(
entity.MeshRefs,
entity.IsBuildingShell,
_physicsDataCache.GetGfxObj);
entityBspCount = bspShapes.Count;
if (entityBspCount > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
bspShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogMultipartRegistration(landblock, entity, bspShapes);
}
SetupPhysics? setup =
_physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setup is not null && entityBspCount == 0)
{
float scale = entity.Scale > 0f ? entity.Scale : 1f;
var setupShapes = new List<ShadowShape>();
for (int cylinderIndex = 0;
cylinderIndex < setup.CylSpheres.Count;
cylinderIndex++)
{
CylSphere cylinder = setup.CylSpheres[cylinderIndex];
float radius = cylinder.Radius * scale;
float baseHeight = cylinder.Height > 0f
? cylinder.Height
: cylinder.Radius * 4f;
float height = baseHeight * scale;
if (radius <= 0f)
continue;
Vector3 localOffset = cylinder.Origin * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setup.CylSpheres.Count == 0)
{
for (int sphereIndex = 0;
sphereIndex < setup.Spheres.Count;
sphereIndex++)
{
Sphere sphere = setup.Spheres[sphereIndex];
if (sphere.Radius <= 0f)
continue;
float radius = sphere.Radius * scale;
Vector3 localOffset = sphere.Origin * scale;
Vector3 localBaseOffset = localOffset
+ Vector3.Transform(
-Vector3.UnitZ * radius,
Quaternion.Inverse(entity.Rotation));
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: localBaseOffset,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: radius * 2f));
}
}
if (setup.CylSpheres.Count == 0
&& setup.Spheres.Count == 0
&& setup.Radius > 0f)
{
float radius = setup.Radius * scale;
float height = (setup.Height > 0f
? setup.Height
: setup.Radius * 2f) * scale;
setupShapes.Add(new ShadowShape(
GfxObjId: entity.SourceGfxObjOrSetupId,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: scale,
CollisionType: ShadowCollisionType.Cylinder,
Radius: radius,
CylHeight: height));
}
if (setupShapes.Count > 0)
{
_physicsEngine.ShadowObjects.RegisterMultiPart(
entity.Id,
entity.Position,
entity.Rotation,
setupShapes,
0u,
EntityCollisionFlags.None,
publication.Origin.X,
publication.Origin.Y,
landblock.LandblockId,
seedCellId: entity.ParentCellId ?? 0u,
isStatic: true);
LogSetupRegistration(landblock, entity, setupShapes);
entityCylinderCount = setupShapes.Count;
}
}
if (entityBspCount > 0)
publication.BspOwnerCount++;
if (entityCylinderCount > 0)
publication.CylinderOwnerCount++;
if (entityBspCount == 0 && entityCylinderCount == 0
&& (sourcePrefix == 0x01000000u
|| sourcePrefix == 0x02000000u))
{
publication.NoCollisionCount++;
}
return landblockInfo.Buildings.Count;
}
private static void LogSetupRegistration(

View file

@ -14,6 +14,10 @@ public readonly record struct LandblockPresentationDiagnostics(
LandblockPhysicsPublisherDiagnostics Physics,
LandblockStaticPresentationDiagnostics Statics);
internal readonly record struct LandblockPublicationAdvance(
bool Completed,
bool Progressed);
/// <summary>
/// Render-thread transaction coordinator for one accepted landblock streaming
/// result. Streaming policy (desired tier, generation, priority, and apply
@ -48,6 +52,7 @@ public sealed class LandblockPresentationPipeline
public required LandblockBuild Build;
public required LandblockMeshData MeshData;
public required uint LandblockId;
public required LandblockStreamCostEstimate Cost;
public LandblockStreamTier Tier;
public bool PresentationCommitted;
public LandblockRenderPublication? RenderPublication;
@ -186,7 +191,19 @@ public sealed class LandblockPresentationPipeline
ArgumentNullException.ThrowIfNull(result);
if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
return;
Advance(result, transaction);
Advance(result, transaction, meter: null, ensureProgress: false);
}
internal LandblockPublicationAdvance ResumePublication(
LandblockStreamResult result,
StreamingWorkMeter meter,
bool ensureProgress)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(meter);
if (!_publications.TryGetValue(result, out PublicationTransaction? transaction))
return new LandblockPublicationAdvance(Completed: true, Progressed: false);
return Advance(result, transaction, meter, ensureProgress);
}
public void AdvanceRetirements() => _retirements.Advance();
@ -223,8 +240,28 @@ public sealed class LandblockPresentationPipeline
loaded.Build,
loaded.MeshData,
loaded.LandblockId,
loaded.Tier);
Advance(loaded, transaction);
loaded.Tier,
estimate: null);
Advance(loaded, transaction, meter: null, ensureProgress: false);
}
internal LandblockPublicationAdvance PublishLoaded(
LandblockStreamResult.Loaded loaded,
LandblockStreamCostEstimate estimate,
StreamingWorkMeter meter,
bool ensureProgress)
{
ArgumentNullException.ThrowIfNull(loaded);
ArgumentNullException.ThrowIfNull(meter);
PublicationTransaction transaction = GetOrCreate(
loaded,
PublicationKind.Loaded,
loaded.Build,
loaded.MeshData,
loaded.LandblockId,
loaded.Tier,
estimate);
return Advance(loaded, transaction, meter, ensureProgress);
}
public void PublishPromoted(
@ -241,8 +278,32 @@ public sealed class LandblockPresentationPipeline
promoted.Build,
promoted.MeshData,
promoted.LandblockId,
LandblockStreamTier.Near);
Advance(promoted, transaction);
LandblockStreamTier.Near,
estimate: null);
Advance(promoted, transaction, meter: null, ensureProgress: false);
}
internal LandblockPublicationAdvance PublishPromoted(
LandblockStreamResult.Promoted promoted,
bool mergeIntoExistingLandblock,
LandblockStreamCostEstimate estimate,
StreamingWorkMeter meter,
bool ensureProgress)
{
ArgumentNullException.ThrowIfNull(promoted);
ArgumentNullException.ThrowIfNull(meter);
PublicationKind kind = mergeIntoExistingLandblock
? PublicationKind.PromoteExisting
: PublicationKind.PromoteSelfContained;
PublicationTransaction transaction = GetOrCreate(
promoted,
kind,
promoted.Build,
promoted.MeshData,
promoted.LandblockId,
LandblockStreamTier.Near,
estimate);
return Advance(promoted, transaction, meter, ensureProgress);
}
public void PublishAsFar(
@ -270,6 +331,11 @@ public sealed class LandblockPresentationPipeline
Origin: completedBuild.Origin),
MeshData = meshData,
LandblockId = farLandblock.LandblockId,
Cost = LandblockStreamResultCost.Estimate(
new LandblockBuild(
farLandblock,
Origin: completedBuild.Origin),
meshData),
Tier = LandblockStreamTier.Far,
};
_publications.Add(acceptedResult, transaction);
@ -280,7 +346,56 @@ public sealed class LandblockPresentationPipeline
$"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
}
Advance(acceptedResult, transaction);
Advance(
acceptedResult,
transaction,
meter: null,
ensureProgress: false);
}
internal LandblockPublicationAdvance PublishAsFar(
LandblockStreamResult acceptedResult,
LandblockBuild completedBuild,
LandblockMeshData meshData,
StreamingWorkMeter meter,
bool ensureProgress)
{
ArgumentNullException.ThrowIfNull(acceptedResult);
ArgumentNullException.ThrowIfNull(completedBuild);
ArgumentNullException.ThrowIfNull(meshData);
ArgumentNullException.ThrowIfNull(meter);
if (!_publications.TryGetValue(
acceptedResult,
out PublicationTransaction? transaction))
{
LoadedLandblock completed = completedBuild.Landblock;
var farLandblock = new LoadedLandblock(
completed.LandblockId,
completed.Heightmap,
Array.Empty<WorldEntity>(),
PhysicsDatBundle.Empty);
LandblockBuild farBuild = new(
farLandblock,
Origin: completedBuild.Origin);
transaction = new PublicationTransaction
{
Kind = PublicationKind.Far,
Build = farBuild,
MeshData = meshData,
LandblockId = farLandblock.LandblockId,
Cost = LandblockStreamResultCost.Estimate(farBuild, meshData),
Tier = LandblockStreamTier.Far,
};
_publications.Add(acceptedResult, transaction);
}
else if (transaction.Kind != PublicationKind.Far)
{
throw new InvalidOperationException(
$"Landblock publication 0x{transaction.LandblockId:X8} changed kind while pending.");
}
return Advance(acceptedResult, transaction, meter, ensureProgress);
}
private PublicationTransaction GetOrCreate(
@ -289,7 +404,8 @@ public sealed class LandblockPresentationPipeline
LandblockBuild build,
LandblockMeshData meshData,
uint landblockId,
LandblockStreamTier tier)
LandblockStreamTier tier,
LandblockStreamCostEstimate? estimate)
{
if (_publications.TryGetValue(result, out PublicationTransaction? existing))
{
@ -311,71 +427,317 @@ public sealed class LandblockPresentationPipeline
Build = build,
MeshData = meshData,
LandblockId = landblockId,
Cost = estimate
?? LandblockStreamResultCost.Estimate(build, meshData),
Tier = tier,
};
_publications.Add(result, created);
return created;
}
private void Advance(
private LandblockPublicationAdvance Advance(
LandblockStreamResult result,
PublicationTransaction transaction)
PublicationTransaction transaction,
StreamingWorkMeter? meter,
bool ensureProgress)
{
bool progressed = false;
bool TryRun(
StreamingWorkCost cost,
string stage,
Action operation)
{
if (meter is null)
{
operation();
progressed = true;
return true;
}
StreamingWorkAdmission admission = meter.TryReserve(
cost,
stage,
ensureProgress && !progressed);
if (admission == StreamingWorkAdmission.Yielded)
return false;
try
{
operation();
meter.Complete();
progressed = true;
return true;
}
catch
{
meter.Fail();
throw;
}
}
if (!transaction.PresentationCommitted)
{
if (_renderPublisher is not null
&& _physicsPublisher is not null
&& _staticPublisher is not null)
{
transaction.RenderPublication ??=
_renderPublisher.PreparePublication(
transaction.Build,
transaction.MeshData);
transaction.PhysicsPublication ??=
_physicsPublisher.PreparePublication(
transaction.RenderPublication);
transaction.StaticPublication ??=
_staticPublisher.PreparePublication(
transaction.PhysicsPublication);
while (transaction.RenderPublication is null)
{
if (!TryRun(
new StreamingWorkCost(EntityOperations: 1),
"publication-prepare-render",
() => transaction.RenderPublication =
_renderPublisher.PreparePublication(
transaction.Build,
transaction.MeshData)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (transaction.PhysicsPublication is null)
{
if (!TryRun(
default,
"publication-prepare-physics",
() => transaction.PhysicsPublication =
_physicsPublisher.CreatePublication(
transaction.RenderPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (!transaction.PhysicsPublication.PreparationCommitted)
{
int entityOperations =
transaction.PhysicsPublication.PreparationCursor
< transaction.Build.Landblock.Entities.Count
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-index-physics",
() => _physicsPublisher.AdvancePreparationOne(
transaction.PhysicsPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (transaction.StaticPublication is null)
{
if (!TryRun(
default,
"publication-prepare-statics",
() => transaction.StaticPublication =
_staticPublisher.CreatePublication(
transaction.PhysicsPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (!transaction.StaticPublication.PreparationCommitted)
{
int entityOperations =
transaction.StaticPublication.PreparationCursor
< transaction.Build.Landblock.Entities.Count
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-validate-statics",
() => _staticPublisher.AdvancePreparationOne(
transaction.StaticPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
// Prepare every immutable receipt before the first external
// mutation. Cross-owner identity/data errors therefore block
// the publication without leaving a committed prefix.
_renderPublisher.BeginPublication(transaction.RenderPublication);
_physicsPublisher.BeginPublication(transaction.PhysicsPublication);
while (!transaction.RenderPublication.BeginCommitted)
{
StreamingWorkCost cost =
!transaction.RenderPublication.TerrainCommitted
? new StreamingWorkCost(
GpuUploadBytes:
transaction.Cost.TerrainPayloadBytes)
: !transaction.RenderPublication.VisibilityCommitted
? new StreamingWorkCost(
EntityOperations:
transaction.Cost.VisibilityCells)
: default;
if (!TryRun(
cost,
"publication-render-prefix",
() => _renderPublisher.AdvanceBeginOne(
transaction.RenderPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (!transaction.PhysicsPublication.BeginCommitted)
{
int entityOperations =
transaction.PhysicsPublication.TerrainSurface is not null
&& transaction.PhysicsPublication.Build.Landblock.PhysicsDats?
.Info is { } info
&& transaction.PhysicsPublication.CellCursor < info.NumCells
? 1
: transaction.PhysicsPublication.BuildingCursor
< transaction.PhysicsPublication.Buildings.Length
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-physics-prefix",
() => _physicsPublisher.AdvanceBeginOne(
transaction.PhysicsPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
// Retail establishes the building/visible-cell suffix before
// each static object publishes lights, collision, and scripts.
_renderPublisher.CompletePublication(
transaction.RenderPublication);
while (!transaction.RenderPublication.CompletionCommitted)
{
int entityOperations =
transaction.RenderPublication.BuildingPublication is
{ PreparationCommitted: false } buildingPublication
&& buildingPublication.BuildingCursor
< buildingPublication.Buildings.Length
? 1
: transaction.RenderPublication.EnvCellPublication is
{ PreparationCommitted: false } envCellPublication
&& envCellPublication.ShellCursor
< envCellPublication.Build.Shells.Length
? 1
: !transaction.RenderPublication.EnvCellsCommitted
&& transaction.RenderPublication
.EnvCellPublication is null
? transaction.Cost.EnvCellShells
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-render-suffix",
() => _renderPublisher.AdvanceCompleteOne(
transaction.RenderPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
_staticPublisher.BeginPublication(transaction.StaticPublication);
_physicsPublisher.CompletePublication(
transaction.PhysicsPublication,
entity => _staticPublisher.PrepareEntityBeforeCollision(
transaction.StaticPublication,
entity));
_staticPublisher.CompletePublication(
transaction.StaticPublication);
while (!transaction.StaticPublication.BeginCommitted)
{
int entityOperations =
transaction.StaticPublication.PriorCleanupCursor
< transaction.StaticPublication
.OrderedPreviouslyActiveIds.Count
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-static-cleanup",
() => _staticPublisher.AdvanceBeginOne(
transaction.StaticPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (!transaction.PhysicsPublication.CompletionCommitted)
{
int entityOperations =
transaction.PhysicsPublication.GfxCursor
< transaction.PhysicsPublication.GfxObjectIds.Length
? 1
: transaction.PhysicsPublication.PriorStaticCursor
< transaction.PhysicsPublication
.PriorStaticOwnerIds.Length
? 1
: transaction.PhysicsPublication.StaticCursor
< transaction.Build.Landblock.Entities.Count
? 1
: transaction.PhysicsPublication
.RefloodOwnerIds is not null
&& transaction.PhysicsPublication
.RefloodCursor
< transaction.PhysicsPublication
.RefloodOwnerIds.Length
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-physics-statics",
() => _physicsPublisher.AdvanceCompleteOne(
transaction.PhysicsPublication,
entity =>
_staticPublisher
.PrepareEntityBeforeCollision(
transaction.StaticPublication,
entity))))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
while (!transaction.StaticPublication.CompletionCommitted)
{
int entityOperations =
transaction.StaticPublication.PluginCursor
< transaction.StaticPublication.OrderedSnapshots.Count
? 1
: 0;
if (!TryRun(
new StreamingWorkCost(
EntityOperations: entityOperations),
"publication-plugin-projection",
() => _staticPublisher.AdvanceCompleteOne(
transaction.StaticPublication)))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
}
else
{
try
StreamingWorkCost legacyCost = new(
EntityOperations: transaction.Cost.Entities,
GpuUploadBytes: transaction.Cost.TerrainPayloadBytes);
bool ran = TryRun(
legacyCost,
"publication-legacy-presentation",
() =>
{
try
{
(_publishBeforeSpatialCommit
?? throw new InvalidOperationException(
"The presentation pipeline has no publication owners."))(
transaction.Build,
transaction.MeshData);
}
catch (Exception error)
{
// Compatibility callbacks cannot report an exact retained
// receipt. Preserve the established fail-fast rule only
// for callbacks that explicitly report committed mutation.
if (error is StreamingMutationException
{
MutationCommitted: true,
})
{
_publications.Remove(result);
}
throw;
}
});
if (!ran)
{
(_publishBeforeSpatialCommit
?? throw new InvalidOperationException(
"The presentation pipeline has no publication owners."))(
transaction.Build,
transaction.MeshData);
}
catch (Exception error)
{
// Compatibility callbacks cannot report an exact retained
// receipt. Preserve the established fail-fast rule only
// for callbacks that explicitly report committed mutation.
if (error is StreamingMutationException { MutationCommitted: true })
_publications.Remove(result);
throw;
return new LandblockPublicationAdvance(false, progressed);
}
}
transaction.PresentationCommitted = true;
@ -387,46 +749,70 @@ public sealed class LandblockPresentationPipeline
// boundary: bucket mutation, pin/script reconciliation, then the
// outer spatial observer commit. If pinning throws, the receipt is
// retained and only that suffix retries.
using GpuWorldState.MutationBatch mutation = _state.BeginMutationBatch();
if (!transaction.SpatialCommitted)
{
IEnumerable<ulong>? renderIds =
transaction.Build.EnvCells?.Shells.Select(static shell => shell.GeometryId);
transaction.SpatialPublication = transaction.Kind switch
{
PublicationKind.Loaded
or PublicationKind.PromoteSelfContained
or PublicationKind.Far =>
_state.CommitLandblockSpatial(
transaction.Build.Landblock,
renderIds,
transaction.Tier),
PublicationKind.PromoteExisting =>
_state.CommitEntitiesToExistingLandblockSpatial(
transaction.LandblockId,
transaction.Build.Landblock.Entities,
renderIds),
_ => throw new InvalidOperationException(
$"Unknown landblock publication kind {transaction.Kind}."),
};
transaction.SpatialCommitted = true;
}
if (!TryRun(
default,
"publication-spatial-commit",
() =>
{
using GpuWorldState.MutationBatch mutation =
_state.BeginMutationBatch();
if (!transaction.SpatialCommitted)
{
IEnumerable<ulong>? renderIds =
transaction.Build.EnvCells?.Shells.Select(
static shell => shell.GeometryId);
transaction.SpatialPublication = transaction.Kind switch
{
PublicationKind.Loaded
or PublicationKind.PromoteSelfContained
or PublicationKind.Far =>
_state.CommitLandblockSpatial(
transaction.Build.Landblock,
renderIds,
transaction.Tier),
PublicationKind.PromoteExisting =>
_state.CommitEntitiesToExistingLandblockSpatial(
transaction.LandblockId,
transaction.Build.Landblock.Entities,
renderIds),
_ => throw new InvalidOperationException(
$"Unknown landblock publication kind {transaction.Kind}."),
};
transaction.SpatialCommitted = true;
}
_state.ActivateLandblockPresentation(
transaction.SpatialPublication
?? throw new InvalidOperationException(
"A committed spatial publication has no activation receipt."));
transaction.SpatialPresentationCommitted = true;
_state.ActivateLandblockPresentation(
transaction.SpatialPublication
?? throw new InvalidOperationException(
"A committed spatial publication has no activation receipt."));
transaction.SpatialPresentationCommitted = true;
}))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
if (!transaction.EnvCellReplayCommitted)
{
if (transaction.Kind != PublicationKind.Far
&& transaction.Build.EnvCells is { Shells.Length: > 0 } envCells)
if (!TryRun(
new StreamingWorkCost(
EntityOperations: transaction.Cost.EnvCellShells),
"publication-envcell-replay",
() =>
{
if (transaction.Kind != PublicationKind.Far
&& transaction.Build.EnvCells is
{
Shells.Length: > 0,
} envCells)
{
_ensureEnvCellMeshes?.Invoke(envCells);
}
transaction.EnvCellReplayCommitted = true;
}))
{
_ensureEnvCellMeshes?.Invoke(envCells);
return new LandblockPublicationAdvance(false, progressed);
}
transaction.EnvCellReplayCommitted = true;
}
if (!transaction.LiveRecoveryCommitted)
@ -434,10 +820,20 @@ public sealed class LandblockPresentationPipeline
// Retained live projections can materialize only after the
// canonical bucket exists. Landblock load never reconstructs their
// identity or replays create-time resources.
_onLandblockLoaded?.Invoke(transaction.LandblockId);
transaction.LiveRecoveryCommitted = true;
if (!TryRun(
default,
"publication-live-recovery",
() =>
{
_onLandblockLoaded?.Invoke(transaction.LandblockId);
transaction.LiveRecoveryCommitted = true;
}))
{
return new LandblockPublicationAdvance(false, progressed);
}
}
_publications.Remove(result);
return new LandblockPublicationAdvance(true, progressed);
}
}

View file

@ -24,7 +24,9 @@ public sealed class LandblockRenderPublication
Vector3 origin,
Dictionary<uint, LoadedCell> visibilityCells,
Vector3 aabbMin,
Vector3 aabbMax)
Vector3 aabbMax,
BuildingRegistryPublication? buildingPublication,
EnvCellLandblockPublication? envCellPublication)
{
Owner = owner;
Build = build;
@ -33,6 +35,8 @@ public sealed class LandblockRenderPublication
_visibilityCells = new ReadOnlyDictionary<uint, LoadedCell>(visibilityCells);
AabbMin = aabbMin;
AabbMax = aabbMax;
BuildingPublication = buildingPublication;
EnvCellPublication = envCellPublication;
}
internal object Owner { get; }
@ -40,6 +44,8 @@ public sealed class LandblockRenderPublication
internal LandblockMeshData MeshData { get; }
internal Vector3 AabbMin { get; }
internal Vector3 AabbMax { get; }
internal BuildingRegistryPublication? BuildingPublication { get; }
internal EnvCellLandblockPublication? EnvCellPublication { get; }
internal bool TerrainCommitted { get; set; }
internal bool VisibilityCommitted { get; set; }
internal bool AabbCommitted { get; set; }
@ -92,6 +98,7 @@ public sealed class LandblockRenderPublisher
private readonly CellVisibility _cellVisibility;
private readonly GpuWorldState _worldState;
private readonly Action<EnvCellLandblockBuild>? _commitEnvCells;
private readonly IEnvCellLandblockPublisher? _envCellPublisher;
private readonly Action<EnvCellLandblockBuild>? _prepareEnvCells;
private readonly Action<uint>? _removeEnvCells;
private readonly Dictionary<uint, BuildingRegistry> _buildingRegistries = new();
@ -114,7 +121,8 @@ public sealed class LandblockRenderPublisher
GpuWorldState worldState,
Action<EnvCellLandblockBuild>? commitEnvCells = null,
Action<EnvCellLandblockBuild>? prepareEnvCells = null,
Action<uint>? removeEnvCells = null)
Action<uint>? removeEnvCells = null,
IEnvCellLandblockPublisher? envCellPublisher = null)
{
ArgumentNullException.ThrowIfNull(publishTerrain);
ArgumentNullException.ThrowIfNull(removeTerrain);
@ -126,6 +134,13 @@ public sealed class LandblockRenderPublisher
_cellVisibility = cellVisibility;
_worldState = worldState;
_commitEnvCells = commitEnvCells;
_envCellPublisher = envCellPublisher;
if (_commitEnvCells is not null && _envCellPublisher is not null)
{
throw new ArgumentException(
"Supply either the retained EnvCell publisher or the compatibility callback, not both.",
nameof(envCellPublisher));
}
_prepareEnvCells = prepareEnvCells;
_removeEnvCells = removeEnvCells;
}
@ -197,6 +212,17 @@ public sealed class LandblockRenderPublisher
visibilityCells[cell.CellId] = cell;
}
(Vector3 aabbMin, Vector3 aabbMax) = ComputeAabb(meshData, origin);
BuildingRegistryPublication? buildingPublication =
build.Landblock.PhysicsDats?.Info is { } info
? BuildingLoader.PreparePublication(
info,
landblockId,
visibilityCells)
: null;
EnvCellLandblockPublication? envCellPublication =
build.EnvCells is { } retainedEnvCells
? _envCellPublisher?.PreparePublication(retainedEnvCells)
: null;
return new LandblockRenderPublication(
_receiptOwner,
build,
@ -204,7 +230,9 @@ public sealed class LandblockRenderPublisher
origin,
visibilityCells,
aabbMin,
aabbMax);
aabbMax,
buildingPublication,
envCellPublication);
}
/// <summary>
@ -214,13 +242,25 @@ public sealed class LandblockRenderPublisher
public void BeginPublication(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return;
while (!AdvanceBeginOne(publication))
{
}
}
long beginStarted = Stopwatch.GetTimestamp();
/// <summary>
/// Advances one exact render-prefix mutation from the retained receipt.
/// Terrain, visibility, and AABB publication remain ordered and retries do
/// not replay an already committed prefix.
/// </summary>
internal bool AdvanceBeginOne(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return true;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId;
if (!publication.TerrainCommitted)
{
long terrainStarted = Stopwatch.GetTimestamp();
@ -228,15 +268,13 @@ public sealed class LandblockRenderPublisher
_terrainPublishTicks += Stopwatch.GetTimestamp() - terrainStarted;
publication.TerrainCommitted = true;
}
if (!publication.VisibilityCommitted)
else if (!publication.VisibilityCommitted)
{
if (build.EnvCells is { } envCells)
_cellVisibility.CommitLandblock(landblockId, envCells.VisibilityCells);
publication.VisibilityCommitted = true;
}
if (!publication.AabbCommitted)
else if (!publication.AabbCommitted)
{
_worldState.SetLandblockAabb(
landblockId,
@ -244,10 +282,14 @@ public sealed class LandblockRenderPublisher
publication.AabbMax);
publication.AabbCommitted = true;
}
else
{
publication.BeginCommitted = true;
_beginCount++;
}
publication.BeginCommitted = true;
_beginCount++;
_beginPublishTicks += Stopwatch.GetTimestamp() - beginStarted;
_beginPublishTicks += Stopwatch.GetTimestamp() - started;
return publication.BeginCommitted;
}
/// <summary>
@ -256,40 +298,77 @@ public sealed class LandblockRenderPublisher
/// receives the complete immutable transaction.
/// </summary>
public void CompletePublication(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Render publication cannot complete before its prefix commits.");
while (!AdvanceCompleteOne(publication))
{
}
}
/// <summary>
/// Advances one exact render-suffix mutation. The building registry is
/// visible before the EnvCell renderer snapshot, matching the existing
/// retail-rooted ordering.
/// </summary>
internal bool AdvanceCompleteOne(LandblockRenderPublication publication)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted)
throw new InvalidOperationException(
"Render publication cannot complete before its prefix commits.");
if (publication.CompletionCommitted)
return;
return true;
long started = Stopwatch.GetTimestamp();
LandblockBuild build = publication.Build;
uint landblockId = publication.LandblockId;
if (!publication.BuildingRegistryCommitted)
if (publication.BuildingPublication is { PreparationCommitted: false }
buildingPublication)
{
if (build.Landblock.PhysicsDats?.Info is { } info)
BuildingLoader.AdvancePreparationOne(buildingPublication);
}
else if (!publication.BuildingRegistryCommitted)
{
if (publication.BuildingPublication is { } completedBuildings)
{
BuildingLoader.CommitPublication(completedBuildings);
uint registryKey = landblockId & 0xFFFF0000u;
_buildingRegistries[registryKey] = BuildingLoader.Build(
info,
landblockId,
publication.VisibilityCells);
_buildingRegistries[registryKey] =
completedBuildings.Registry;
}
publication.BuildingRegistryCommitted = true;
}
if (!publication.EnvCellsCommitted)
else if (publication.EnvCellPublication is { } envCellPublication
&& !envCellPublication.PreparationCommitted)
{
if (build.EnvCells is { } envCells)
_envCellPublisher?.AdvancePreparationOne(envCellPublication);
}
else if (!publication.EnvCellsCommitted)
{
if (publication.EnvCellPublication is { } retained)
{
(_envCellPublisher
?? throw new InvalidOperationException(
"A retained EnvCell receipt has no owning publisher."))
.CommitPublication(retained);
}
else if (build.EnvCells is { } envCells)
{
_commitEnvCells?.Invoke(envCells);
}
publication.EnvCellsCommitted = true;
}
else
{
publication.CompletionCommitted = true;
_completeCount++;
}
publication.CompletionCommitted = true;
_completeCount++;
_completePublishTicks += Stopwatch.GetTimestamp() - started;
return publication.CompletionCommitted;
}
/// <summary>

View file

@ -13,26 +13,46 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class LandblockStaticPresentationPublication
{
private readonly IReadOnlyDictionary<uint, WorldEntity> _entities;
private readonly IReadOnlyDictionary<uint, WorldEntitySnapshot> _snapshots;
private readonly Dictionary<uint, WorldEntity> _entities;
private readonly Dictionary<uint, WorldEntitySnapshot> _snapshots;
private readonly uint[] _orderedPreviouslyActiveIds;
private readonly Dictionary<uint, WorldEntitySnapshot> _replacementActive;
internal LandblockStaticPresentationPublication(
object owner,
LandblockPhysicsPublication physicsPublication,
Dictionary<uint, WorldEntity> entities,
Dictionary<uint, WorldEntitySnapshot> snapshots,
HashSet<uint> previouslyActiveIds)
IReadOnlyDictionary<uint, WorldEntitySnapshot> priorActive,
HashSet<uint> previouslyActiveIds,
uint[] orderedPreviouslyActiveIds)
{
Owner = owner;
PhysicsPublication = physicsPublication;
_entities = entities;
_snapshots = snapshots;
PriorActive = priorActive;
PreviouslyActiveIds = previouslyActiveIds;
_orderedPreviouslyActiveIds = orderedPreviouslyActiveIds;
_replacementActive = new Dictionary<uint, WorldEntitySnapshot>(
physicsPublication.Build.Landblock.Entities.Count);
}
internal object Owner { get; }
internal LandblockPhysicsPublication PhysicsPublication { get; }
internal IReadOnlyDictionary<uint, WorldEntitySnapshot> PriorActive { get; }
internal Dictionary<uint, WorldEntity> MutableEntities => _entities;
internal Dictionary<uint, WorldEntitySnapshot> MutableSnapshots => _snapshots;
internal HashSet<uint> PreviouslyActiveIds { get; }
internal IReadOnlyList<uint> OrderedPreviouslyActiveIds =>
_orderedPreviouslyActiveIds;
internal IReadOnlyList<KeyValuePair<uint, WorldEntitySnapshot>>
OrderedSnapshots { get; set; } =
Array.Empty<KeyValuePair<uint, WorldEntitySnapshot>>();
internal Dictionary<uint, WorldEntitySnapshot> ReplacementActive =>
_replacementActive;
internal int PreparationCursor { get; set; }
internal bool PreparationCommitted { get; set; }
internal int PriorCleanupCursor { get; set; }
internal int PluginCursor { get; set; }
internal bool BeginCommitted { get; set; }
@ -116,6 +136,22 @@ public sealed class LandblockStaticPresentationPublisher
public LandblockStaticPresentationPublication PreparePublication(
LandblockPhysicsPublication physicsPublication)
{
LandblockStaticPresentationPublication publication =
CreatePublication(physicsPublication);
while (!AdvancePreparationOne(publication))
{
}
return publication;
}
/// <summary>
/// Captures prior ownership without validating the replacement entity
/// list. The concrete pipeline advances replacement validation one entity
/// at a time before any render/physics owner mutates external state.
/// </summary>
internal LandblockStaticPresentationPublication CreatePublication(
LandblockPhysicsPublication physicsPublication)
{
ArgumentNullException.ThrowIfNull(physicsPublication);
@ -125,11 +161,43 @@ public sealed class LandblockStaticPresentationPublisher
out Dictionary<uint, WorldEntitySnapshot>? active);
var entities = new Dictionary<uint, WorldEntity>();
var snapshots = new Dictionary<uint, WorldEntitySnapshot>();
foreach (WorldEntity entity in physicsPublication.Build.Landblock.Entities)
HashSet<uint> previous = active is not null
? active.Keys.ToHashSet()
: new HashSet<uint>();
uint[] orderedPrevious = previous.Order().ToArray();
return new LandblockStaticPresentationPublication(
_receiptOwner,
physicsPublication,
entities,
snapshots,
active ?? new Dictionary<uint, WorldEntitySnapshot>(),
previous,
orderedPrevious);
}
/// <summary>
/// Validates and snapshots one replacement entity. The final stable plugin
/// order is materialized only after the cursor reaches the immutable tail.
/// </summary>
internal bool AdvancePreparationOne(
LandblockStaticPresentationPublication publication)
{
ValidateReceipt(publication);
if (publication.PreparationCommitted)
return true;
IReadOnlyList<WorldEntity> source =
publication.PhysicsPublication.Build.Landblock.Entities;
uint canonical = Canonicalize(publication.LandblockId);
if (publication.PreparationCursor < source.Count)
{
WorldEntity entity = source[publication.PreparationCursor];
if (entity.ServerGuid != 0)
continue;
if (!entities.TryAdd(entity.Id, entity))
{
publication.PreparationCursor++;
return false;
}
if (publication.Entities.ContainsKey(entity.Id))
{
throw new InvalidOperationException(
$"Landblock 0x{canonical:X8} contains duplicate DAT-static ID " +
@ -142,8 +210,9 @@ public sealed class LandblockStaticPresentationPublisher
$"DAT-static ID 0x{entity.Id:X8} is already owned by " +
$"landblock 0x{owner:X8}.");
}
if (active is not null
&& active.TryGetValue(entity.Id, out WorldEntitySnapshot retained)
if (publication.PriorActive.TryGetValue(
entity.Id,
out WorldEntitySnapshot retained)
&& retained.SourceId != entity.SourceGfxObjOrSetupId)
{
throw new InvalidOperationException(
@ -152,18 +221,17 @@ public sealed class LandblockStaticPresentationPublisher
$"0x{entity.SourceGfxObjOrSetupId:X8}.");
}
snapshots.Add(entity.Id, Snapshot(entity));
publication.MutableEntities.Add(entity.Id, entity);
publication.MutableSnapshots.Add(entity.Id, Snapshot(entity));
publication.PreparationCursor++;
return false;
}
HashSet<uint> previous = active is not null
? active.Keys.ToHashSet()
: new HashSet<uint>();
return new LandblockStaticPresentationPublication(
_receiptOwner,
physicsPublication,
entities,
snapshots,
previous);
publication.OrderedSnapshots = publication.Snapshots
.OrderBy(static pair => pair.Key)
.ToArray();
publication.PreparationCommitted = true;
return true;
}
/// <summary>
@ -174,13 +242,31 @@ public sealed class LandblockStaticPresentationPublisher
public void BeginPublication(LandblockStaticPresentationPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return;
uint[] priorIds = publication.PreviouslyActiveIds.Order().ToArray();
while (publication.PriorCleanupCursor < priorIds.Length)
if (!publication.PreparationCommitted)
throw new InvalidOperationException(
"Static presentation cannot begin before preparation commits.");
while (!AdvanceBeginOne(publication))
{
uint id = priorIds[publication.PriorCleanupCursor];
}
}
/// <summary>
/// Advances exactly one retained prior-owner cleanup. The cursor advances
/// only after the complete owner operation succeeds, so a failure retries
/// the same ID without rebuilding or reordering the work list.
/// </summary>
internal bool AdvanceBeginOne(
LandblockStaticPresentationPublication publication)
{
ValidateReceipt(publication);
if (publication.BeginCommitted)
return true;
if (publication.PriorCleanupCursor
< publication.OrderedPreviouslyActiveIds.Count)
{
uint id = publication.OrderedPreviouslyActiveIds[
publication.PriorCleanupCursor];
bool retained = publication.Entities.ContainsKey(id);
_lighting.UnregisterOwner(id, forgetState: !retained);
if (!retained)
@ -192,10 +278,12 @@ public sealed class LandblockStaticPresentationPublisher
_pluginRemovalCount++;
}
publication.PriorCleanupCursor++;
return false;
}
publication.BeginCommitted = true;
_beginCount++;
return true;
}
/// <summary>
@ -258,15 +346,32 @@ public sealed class LandblockStaticPresentationPublisher
throw new InvalidOperationException(
"Static presentation requires completed physics publication.");
}
if (publication.CompletionCommitted)
return;
KeyValuePair<uint, WorldEntitySnapshot>[] snapshots = publication.Snapshots
.OrderBy(pair => pair.Key)
.ToArray();
while (publication.PluginCursor < snapshots.Length)
while (!AdvanceCompleteOne(publication))
{
(uint id, WorldEntitySnapshot snapshot) = snapshots[publication.PluginCursor];
}
}
/// <summary>
/// Advances exactly one plugin projection, then atomically publishes the
/// retained replacement dictionary after the cursor reaches its tail.
/// </summary>
internal bool AdvanceCompleteOne(
LandblockStaticPresentationPublication publication)
{
ValidateReceipt(publication);
if (!publication.BeginCommitted
|| !publication.PhysicsPublication.CompletionCommitted)
{
throw new InvalidOperationException(
"Static presentation requires completed physics publication.");
}
if (publication.CompletionCommitted)
return true;
if (publication.PluginCursor < publication.OrderedSnapshots.Count)
{
(uint id, WorldEntitySnapshot snapshot) =
publication.OrderedSnapshots[publication.PluginCursor];
_worldState.Add(snapshot);
if (publication.PreviouslyActiveIds.Contains(id))
{
@ -279,21 +384,19 @@ public sealed class LandblockStaticPresentationPublisher
_pluginSpawnCount++;
}
_landblockByEntityId[id] = Canonicalize(publication.LandblockId);
publication.ReplacementActive[id] = snapshot;
publication.PluginCursor++;
return false;
}
uint canonical = Canonicalize(publication.LandblockId);
if (publication.Snapshots.Count == 0)
{
if (publication.ReplacementActive.Count == 0)
_activeByLandblock.Remove(canonical);
}
else
{
_activeByLandblock[canonical] = publication.Snapshots
.ToDictionary(pair => pair.Key, pair => pair.Value);
}
_activeByLandblock[canonical] = publication.ReplacementActive;
publication.CompletionCommitted = true;
_completeCount++;
return true;
}
public void RemoveLighting(WorldEntity entity)

View file

@ -29,18 +29,7 @@ internal readonly record struct StreamingQueuedCompletion(
StreamingCompletionPriority Priority,
ulong Generation,
long Sequence,
long EnqueuedTimestamp)
{
public StreamingWorkCost AdmissionCost => new(
CompletionAdmissions: Estimate.Work.CompletionAdmissions,
AdoptedCpuBytes: Estimate.Work.AdoptedCpuBytes);
public StreamingWorkCost ExecutionCost => new(
EntityOperations: Estimate.Work.EntityOperations,
GpuUploadBytes: Estimate.Work.GpuUploadBytes,
GlRetireOperations:
Result is LandblockStreamResult.Unloaded ? 1 : 0);
}
long EnqueuedTimestamp);
internal readonly record struct StreamingCompletionQueueSnapshot(
int Count,

View file

@ -370,7 +370,11 @@ public sealed class StreamingController : IStreamingFrameBackend
// the desired-tier snapshot below is computed. Otherwise a landblock
// that becomes spatially resident during convergence is absent from
// the reconfiguration mutation ledger.
ConvergePendingPublications();
if (!ConvergePendingPublications())
{
_deferredRadiiRequest = (nearRadius, farRadius);
return;
}
if (_collapsed || _region is null)
{
@ -490,6 +494,12 @@ public sealed class StreamingController : IStreamingFrameBackend
_presentation.GetPendingPublicationResults();
if (pending.Count == 0)
return true;
if (_activeWorkMeter is null)
{
// Settings/native callbacks may request policy changes between
// frames, but publication remains owned by the frame-scoped meter.
return false;
}
try
{
@ -497,15 +507,14 @@ public sealed class StreamingController : IStreamingFrameBackend
for (int i = 0; i < pending.Count; i++)
{
LandblockStreamResult result = pending[i];
if (!TryObserveResultOperation(
result,
"publication-retry",
() => _presentation.ResumePublication(result),
ensureProgress: !progressed))
{
LandblockPublicationAdvance advance =
_presentation.ResumePublication(
result,
_activeWorkMeter,
ensureProgress: !progressed);
progressed |= advance.Progressed;
if (!advance.Completed)
return false;
}
progressed = true;
}
return true;
}
@ -1487,23 +1496,16 @@ public sealed class StreamingController : IStreamingFrameBackend
StreamingQueuedCompletion work = completion
?? throw new InvalidOperationException(
"The completion queue returned a null head.");
StreamingWorkCost executionCost =
IsStaleGeneration(work.Result)
? default
: work.ExecutionCost;
StreamingWorkAdmission admission = meter.TryReserve(
executionCost,
$"execute-{work.Priority}",
ensureProgress: !executed);
if (admission == StreamingWorkAdmission.Yielded)
break;
try
{
ApplyResult(work.Result);
LandblockPublicationAdvance advance = ApplyResult(
work,
meter,
ensureProgress: !executed);
executed |= advance.Progressed;
if (!advance.Completed)
break;
_completionQueue.RemoveHead(work);
meter.Complete();
executed = true;
}
catch
{
@ -1512,7 +1514,6 @@ public sealed class StreamingController : IStreamingFrameBackend
// receipt cannot safely replay, so consume only that result.
if (!_presentation.HasPendingPublication(work.Result))
_completionQueue.RemoveHead(work);
meter.Fail();
throw;
}
}
@ -1625,39 +1626,24 @@ public sealed class StreamingController : IStreamingFrameBackend
};
}
private bool TryObserveResultOperation(
LandblockStreamResult result,
private static LandblockPublicationAdvance RunSimpleResultOperation(
StreamingWorkMeter meter,
string stage,
Action operation,
StreamingWorkCost? knownCost = null,
bool ensureProgress = false)
bool ensureProgress,
Action operation)
{
StreamingWorkMeter? meter = _activeWorkMeter;
if (meter is null)
{
operation();
return true;
}
StreamingWorkCost aggregate = knownCost
?? LandblockStreamResultCost.Estimate(result).Work;
StreamingWorkCost cost = new(
EntityOperations: aggregate.EntityOperations,
GpuUploadBytes: aggregate.GpuUploadBytes,
GlRetireOperations:
result is LandblockStreamResult.Unloaded ? 1 : 0);
StreamingWorkAdmission admission = meter.TryReserve(
cost,
default,
stage,
ensureProgress);
if (admission == StreamingWorkAdmission.Yielded)
return false;
return new LandblockPublicationAdvance(false, false);
try
{
operation();
meter.Complete();
return true;
return new LandblockPublicationAdvance(true, true);
}
catch
{
@ -1671,13 +1657,22 @@ public sealed class StreamingController : IStreamingFrameBackend
/// effects: terrain upload, GPU state, and the re-hydration callback.
/// All priority queues route through this one publication path.
/// </summary>
private void ApplyResult(LandblockStreamResult result)
private LandblockPublicationAdvance ApplyResult(
StreamingQueuedCompletion work,
StreamingWorkMeter meter,
bool ensureProgress)
{
LandblockStreamResult result = work.Result;
if (_presentation.HasPendingPublication(result))
{
_presentation.ResumePublication(result);
ReconcileCompletedPendingPublication(result.LandblockId);
return;
LandblockPublicationAdvance resumed =
_presentation.ResumePublication(
result,
meter,
ensureProgress);
if (resumed.Completed)
ReconcileCompletedPendingPublication(result.LandblockId);
return resumed;
}
if (IsStaleGeneration(result))
@ -1685,7 +1680,11 @@ public sealed class StreamingController : IStreamingFrameBackend
// A worker can finish one old load/unload after a hard recenter.
// Landblock id membership cannot distinguish overlapping windows;
// generation is the logical streaming incarnation boundary.
return;
return RunSimpleResultOperation(
meter,
"execute-stale-generation",
ensureProgress,
static () => { });
}
if (result is LandblockStreamResult.Unloaded
@ -1695,7 +1694,11 @@ public sealed class StreamingController : IStreamingFrameBackend
// rapid away->back can therefore re-own an id while its same-
// generation unload is already in the worker outbox. Current
// region ownership wins; the old unload must not tear it down.
return;
return RunSimpleResultOperation(
meter,
"execute-reowned-unload",
ensureProgress,
static () => { });
}
if (result is LandblockStreamResult.Loaded
@ -1707,7 +1710,11 @@ public sealed class StreamingController : IStreamingFrameBackend
// A hard recenter/collapse can leave one worker job already in
// flight. Its completion belongs to the old region and must not
// resurrect a landblock the new StreamingRegion never owns.
return;
return RunSimpleResultOperation(
meter,
"execute-undesired-load",
ensureProgress,
static () => { });
}
bool isNearCompletion = result is LandblockStreamResult.Promoted
@ -1722,7 +1729,11 @@ public sealed class StreamingController : IStreamingFrameBackend
// high-priority jobs. Publication is idempotent at this owner:
// never append statics, replay defaults, or reapply the complete
// Near transaction to an already-Near landblock.
return;
return RunSimpleResultOperation(
meter,
"execute-duplicate-near",
ensureProgress,
static () => { });
}
if (desiredTier == LandblockStreamTier.Far && isNearCompletion)
{
@ -1739,14 +1750,26 @@ public sealed class StreamingController : IStreamingFrameBackend
switch (result)
{
case LandblockStreamResult.Loaded loaded:
_presentation.PublishAsFar(loaded, loaded.Build, loaded.MeshData);
break;
return _presentation.PublishAsFar(
loaded,
loaded.Build,
loaded.MeshData,
meter,
ensureProgress);
case LandblockStreamResult.Promoted promoted:
_presentation.PublishAsFar(promoted, promoted.Build, promoted.MeshData);
break;
return _presentation.PublishAsFar(
promoted,
promoted.Build,
promoted.MeshData,
meter,
ensureProgress);
}
}
return;
return RunSimpleResultOperation(
meter,
"execute-already-far",
ensureProgress,
static () => { });
}
}
@ -1759,31 +1782,54 @@ public sealed class StreamingController : IStreamingFrameBackend
// This Far completion was queued before a newer Near load
// or promotion. Applying it would erase the entity/cell
// layer that now owns the landblock.
break;
return RunSimpleResultOperation(
meter,
"execute-stale-far-tier",
ensureProgress,
static () => { });
}
_presentation.PublishLoaded(loaded);
break;
return _presentation.PublishLoaded(
loaded,
work.Estimate,
meter,
ensureProgress);
case LandblockStreamResult.Promoted promoted:
// PromoteToNear carries a complete build and mesh because the
// streamer deliberately lets it supersede a queued LoadFar. If
// that Far job never started, publish this as the real Near
// landblock; if the base is already resident, merge only the
// Near layer so existing live projections retain identity.
_presentation.PublishPromoted(
return _presentation.PublishPromoted(
promoted,
mergeIntoExistingLandblock: _state.IsLoaded(promoted.LandblockId));
break;
mergeIntoExistingLandblock:
_state.IsLoaded(promoted.LandblockId),
work.Estimate,
meter,
ensureProgress);
case LandblockStreamResult.Unloaded unloaded:
_presentation.EnqueueFullRetirement(unloaded.LandblockId);
break;
return RunSimpleResultOperation(
meter,
"execute-unload",
ensureProgress,
() => _presentation.EnqueueFullRetirement(
unloaded.LandblockId));
case LandblockStreamResult.Failed failed:
Console.WriteLine(
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}");
break;
return RunSimpleResultOperation(
meter,
"execute-load-failure",
ensureProgress,
() => Console.WriteLine(
$"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"));
case LandblockStreamResult.WorkerCrashed crashed:
Console.WriteLine(
$"streaming: worker CRASHED: {crashed.Error}");
break;
return RunSimpleResultOperation(
meter,
"execute-worker-crash",
ensureProgress,
() => Console.WriteLine(
$"streaming: worker CRASHED: {crashed.Error}"));
default:
throw new InvalidOperationException(
$"Unsupported streaming result {result.GetType().Name}.");
}
}

View file

@ -455,6 +455,18 @@ public sealed class ShadowObjectRegistry
/// cells hydrated after a server spawn landed.
/// </summary>
public void RefloodLandblock(uint landblockId)
{
uint[] owners = CaptureRefloodOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
RefloodOwnerForLandblock(owners[i], landblockId);
}
/// <summary>
/// Captures the stable ordered owner set touched by one landblock reflood.
/// App-layer streaming can retain this receipt and advance one owner per
/// frame without changing the collision registry's ownership rules.
/// </summary>
public uint[] CaptureRefloodOwnersForLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toReflood = new HashSet<uint>();
@ -487,39 +499,81 @@ public sealed class ShadowObjectRegistry
}
}
foreach (uint entityId in toReflood)
uint[] ordered = toReflood.ToArray();
Array.Sort(ordered);
return ordered;
}
/// <summary>
/// Re-runs one owner from a retained landblock-reflood receipt. A removed,
/// suspended, or otherwise superseded owner is an idempotent no-op.
/// </summary>
public void RefloodOwnerForLandblock(uint entityId, uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
if (_suspendedEntities.Contains(entityId)
|| !_entityReg.TryGetValue(
entityId,
out RegistrationRecord? reg))
{
var reg = _entityReg[entityId];
_withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawnBeforeReflood);
if (reg.IsMultiPart && _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(entityId, reg.EntityWorldPos, reg.EntityWorldRot, shapes,
reg.State, reg.Flags, 0f, 0f, lbPrefix,
reg.SeedCellId, reg.IsStatic);
}
else
{
Register(entityId, reg.GfxObjId, reg.EntityWorldPos, reg.EntityWorldRot,
reg.Radius, 0f, 0f, lbPrefix,
reg.CollisionType, reg.CylHeight, reg.Scale,
reg.State, reg.Flags, reg.SeedCellId, reg.IsStatic);
}
return;
}
// Register is also the authoritative movement/replacement API and
// therefore clears obsolete markers. Only this streaming reflood
// operation preserves the still-missing prefixes across replacement.
if (withdrawnBeforeReflood is not null)
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
_withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawnBeforeReflood);
if (reg.IsMultiPart
&& _entityShapes.TryGetValue(entityId, out var shapes))
{
RegisterMultiPart(
entityId,
reg.EntityWorldPos,
reg.EntityWorldRot,
shapes,
reg.State,
reg.Flags,
0f,
0f,
lbPrefix,
reg.SeedCellId,
reg.IsStatic);
}
else
{
Register(
entityId,
reg.GfxObjId,
reg.EntityWorldPos,
reg.EntityWorldRot,
reg.Radius,
0f,
0f,
lbPrefix,
reg.CollisionType,
reg.CylHeight,
reg.Scale,
reg.State,
reg.Flags,
reg.SeedCellId,
reg.IsStatic);
}
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
&& refreshedCells.Exists(cell =>
(cell & 0xFFFF0000u) == lbPrefix)
&& _withdrawnPrefixesByOwner.TryGetValue(entityId, out var withdrawn))
{
withdrawn.Remove(lbPrefix);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
// Register is also the authoritative movement/replacement API and
// therefore clears obsolete markers. Only this streaming reflood
// operation preserves the still-missing prefixes across replacement.
if (withdrawnBeforeReflood is not null)
_withdrawnPrefixesByOwner[entityId] = withdrawnBeforeReflood;
if (_entityToCells.TryGetValue(entityId, out var refreshedCells)
&& refreshedCells.Exists(cell =>
(cell & 0xFFFF0000u) == lbPrefix)
&& _withdrawnPrefixesByOwner.TryGetValue(
entityId,
out var withdrawn))
{
withdrawn.Remove(lbPrefix);
if (withdrawn.Count == 0)
_withdrawnPrefixesByOwner.Remove(entityId);
}
}
@ -596,6 +650,17 @@ public sealed class ShadowObjectRegistry
/// are deliberately retained for spatial reflood after streaming changes.
/// </summary>
public void DeregisterStaticOwnersForLandblock(uint landblockId)
{
uint[] owners = CaptureStaticOwnersForLandblock(landblockId);
for (int i = 0; i < owners.Length; i++)
DeregisterStaticOwnerForLandblock(owners[i], landblockId);
}
/// <summary>
/// Captures a stable ordered receipt for static collision owners rooted in
/// one landblock.
/// </summary>
public uint[] CaptureStaticOwnersForLandblock(uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
var owners = new List<uint>();
@ -607,9 +672,27 @@ public sealed class ShadowObjectRegistry
owners.Add(entityId);
}
}
owners.Sort();
return owners.ToArray();
}
foreach (uint entityId in owners)
/// <summary>
/// Removes one static owner from a retained landblock receipt. If the
/// owner was already removed or rebound elsewhere, the operation no-ops.
/// </summary>
public void DeregisterStaticOwnerForLandblock(
uint entityId,
uint landblockId)
{
uint prefix = landblockId & 0xFFFF0000u;
if (_entityReg.TryGetValue(
entityId,
out RegistrationRecord? registration)
&& registration.IsStatic
&& (registration.SeedCellId & 0xFFFF0000u) == prefix)
{
Deregister(entityId);
}
}
/// <summary>

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
@ -86,6 +87,43 @@ public class BuildingLoaderTests
Assert.Equal(new SortedSet<uint> { 1, 2 }, ids); // sequential 1, 2
}
[Fact]
public void RetainedPublication_AdvancesOneBuildingAndCommitsCellStampsAtomically()
{
LandBlockInfo info = MakeInfo(
(0x02000001u, new[] { 0x0150u }),
(0x02000002u, new[] { 0x0160u }));
LoadedCell cell150 = MinimalCell(0xA9B40150u);
LoadedCell cell160 = MinimalCell(0xA9B40160u);
var cells = new Dictionary<uint, LoadedCell>
{
[cell150.CellId] = cell150,
[cell160.CellId] = cell160,
};
BuildingRegistryPublication publication =
BuildingLoader.PreparePublication(info, 0xA9B40000u, cells);
Assert.False(publication.PreparationCommitted);
Assert.False(BuildingLoader.AdvancePreparationOne(publication));
Assert.Equal(1, publication.BuildingCursor);
Assert.Equal(1, publication.Registry.Count);
Assert.Null(cell150.BuildingId);
Assert.Null(cell160.BuildingId);
Assert.True(BuildingLoader.AdvancePreparationOne(publication));
Assert.Equal(2, publication.BuildingCursor);
Assert.Equal(2, publication.Registry.Count);
Assert.Null(cell150.BuildingId);
Assert.Null(cell160.BuildingId);
BuildingLoader.CommitPublication(publication);
Assert.Equal(1u, cell150.BuildingId);
Assert.Equal(2u, cell160.BuildingId);
Assert.True(publication.PublicationCommitted);
}
[Fact]
public void Build_StampsLoadedCellBuildingId()
{
@ -166,4 +204,16 @@ public class BuildingLoaderTests
Assert.Equal(new Vector3(9, 12, 33), b.PortalBounds.Min);
Assert.Equal(new Vector3(17, 25, 39), b.PortalBounds.Max);
}
private static LoadedCell MinimalCell(uint cellId) => new()
{
CellId = cellId,
Portals = new List<AcDream.App.Rendering.CellPortalInfo>(),
PortalPolygons = new List<Vector3[]>(),
WorldTransform = Matrix4x4.Identity,
InverseWorldTransform = Matrix4x4.Identity,
LocalBoundsMin = new Vector3(-5, -5, -5),
LocalBoundsMax = new Vector3(5, 5, 5),
ClipPlanes = new List<AcDream.App.Rendering.PortalClipPlane>(),
};
}

View file

@ -56,6 +56,73 @@ public sealed class LandblockConcretePresentationPipelineTests
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
}
[Fact]
public void MeteredLoaded_RetainsExactHeadAndPublishesSpatiallyOnlyAfterOwnerSuffix()
{
var calls = new List<string>();
ConcreteFixture fixture = Fixture(
calls,
commitEnvCells: _ => calls.Add("envcell"));
fixture.Events.EntitySpawned += _ => calls.Add("plugin");
var pipeline = new LandblockPresentationPipeline(
fixture.Render,
fixture.Physics,
fixture.Static,
fixture.State,
fixture.RetirementOwner,
onLandblockLoaded: _ => calls.Add("live-recovery"));
LandblockStreamResult.Loaded result =
Result(Build(Entity(0x80A9B401u), Entity(0x80A9B402u)));
LandblockStreamCostEstimate estimate =
LandblockStreamResultCost.Estimate(result);
var budget = new StreamingWorkBudget(
TimeSpan.FromSeconds(1),
maxCompletionAdmissions: 64,
maxAdoptedCpuBytes: 1_000_000,
maxEntityOperations: 1,
maxGpuUploadBytes: 1_000_000,
maxGlRetireOperations: 64,
destinationReserveFraction: 0.75f);
LandblockPublicationAdvance advance = default;
for (int frame = 0; frame < 64; frame++)
{
var meter = new StreamingWorkMeter(budget);
advance = frame == 0
? pipeline.PublishLoaded(
result,
estimate,
meter,
ensureProgress: false)
: pipeline.ResumePublication(
result,
meter,
ensureProgress: false);
meter.FinishFrame();
Assert.True(
meter.Snapshot.Used.EntityOperations <= 1,
$"stage={meter.Snapshot.LastStage}, " +
$"entities={meter.Snapshot.Used.EntityOperations}, " +
$"oversized={meter.Snapshot.OversizedProgressCount}");
if (fixture.Static.Diagnostics.CompleteCount == 0)
Assert.False(fixture.State.IsLoaded(LandblockId));
if (advance.Completed)
break;
}
Assert.True(advance.Completed);
Assert.False(pipeline.HasPendingPublication(result));
Assert.True(fixture.State.IsNearTier(LandblockId));
Assert.Equal(
["terrain", "envcell", "plugin", "plugin", "pin", "live-recovery"],
calls);
Assert.Equal(1, fixture.Render.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.BeginCount);
Assert.Equal(1, fixture.Physics.Diagnostics.CompleteCount);
Assert.Equal(1, fixture.Static.Diagnostics.CompleteCount);
}
[Fact]
public void RenderSuffixFailure_ResumesConcreteReceiptsWithoutReplayingPrefixes()
{

View file

@ -1119,7 +1119,15 @@ public sealed class LandblockPresentationPipelineTests
failReplayOnce = false;
throw new InvalidOperationException("injected replay failure");
}
});
},
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 1_000_000,
MaxEntityOperations: 1_000,
MaxGpuUploadBytes: 1_000_000,
MaxGlRetireOperations: 1_000,
DestinationReserveFraction: 0.75f));
Assert.Throws<InvalidOperationException>(() => controller.Tick(0x90, 0x90));
Assert.True(state.IsNearTier(landblockId));

View file

@ -1,5 +1,6 @@
using System.Numerics;
using System.Reflection;
using System.Collections.Immutable;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
@ -194,6 +195,47 @@ public sealed class LandblockRenderPublisherTests
Assert.Equal(1, first.Diagnostics.CompleteCount);
}
[Fact]
public void RetainedEnvCellPublisher_AdvancesOneShellBeforeAtomicCommit()
{
var envPublisher = new RecordingEnvCellPublisher();
EnvCellLandblockBuild envCells = new(
LandblockId,
Array.Empty<LoadedCell>(),
[
Shell(0xA9B40100u, 1),
Shell(0xA9B40101u, 2),
Shell(0xA9B40102u, 3),
]);
var publisher = new LandblockRenderPublisher(
publishTerrain: static (_, _, _) => { },
removeTerrain: static _ => { },
cellVisibility: new CellVisibility(),
worldState: new GpuWorldState(),
envCellPublisher: envPublisher);
LandblockRenderPublication receipt = publisher.PreparePublication(
Build(envCells),
EmptyMesh());
publisher.BeginPublication(receipt);
Assert.False(publisher.AdvanceCompleteOne(receipt));
Assert.Equal(0, envPublisher.AdvancedShells);
Assert.Equal(0, envPublisher.CommitCount);
for (int shell = 1; shell <= 3; shell++)
{
Assert.False(publisher.AdvanceCompleteOne(receipt));
Assert.Equal(shell, envPublisher.AdvancedShells);
Assert.Equal(0, envPublisher.CommitCount);
}
Assert.False(publisher.AdvanceCompleteOne(receipt));
Assert.Equal(0, envPublisher.CommitCount);
Assert.False(publisher.AdvanceCompleteOne(receipt));
Assert.Equal(1, envPublisher.CommitCount);
Assert.True(publisher.AdvanceCompleteOne(receipt));
Assert.Equal(1, publisher.Diagnostics.CompleteCount);
}
[Fact]
public void PrepareAndRetirementOperationsStayBalancedAtTheirOwnerBoundary()
{
@ -341,6 +383,19 @@ public sealed class LandblockRenderPublisherTests
InverseWorldTransform = Matrix4x4.Identity,
};
private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId) =>
new(
cellId,
geometryId,
EnvironmentId: 1u,
CellStructure: 0,
Surfaces: ImmutableArray<ushort>.Empty,
WorldPosition: Vector3.Zero,
Rotation: Quaternion.Identity,
Transform: Matrix4x4.Identity,
LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One),
WorldBounds: new WbBoundingBox(Vector3.Zero, Vector3.One));
private static LandblockMeshData MeshAtHeights(float first, float second) => new(
[
new TerrainVertex(new Vector3(0f, 0f, first), Vector3.UnitZ, 0, 0, 0, 0),
@ -362,4 +417,42 @@ public sealed class LandblockRenderPublisherTests
}
throw new DirectoryNotFoundException("Could not locate repository root.");
}
private sealed class RecordingEnvCellPublisher :
IEnvCellLandblockPublisher
{
private readonly object _owner = new();
public int AdvancedShells { get; private set; }
public int CommitCount { get; private set; }
public EnvCellLandblockPublication PreparePublication(
EnvCellLandblockBuild build) =>
new(_owner, build);
public bool AdvancePreparationOne(
EnvCellLandblockPublication publication)
{
Assert.Same(_owner, publication.Owner);
if (publication.PreparationCommitted)
return true;
if (publication.ShellCursor < publication.Build.Shells.Length)
{
publication.ShellCursor++;
AdvancedShells++;
return false;
}
publication.PreparationCommitted = true;
return true;
}
public void CommitPublication(
EnvCellLandblockPublication publication)
{
Assert.True(publication.PreparationCommitted);
publication.PublicationCommitted = true;
CommitCount++;
}
}
}

View file

@ -507,7 +507,16 @@ public sealed class LandblockRetirementCoordinatorTests
nearRadius: 0,
farRadius: 0,
retirementCoordinator: coordinator,
workBudgetOptions: workBudgetOptions);
workBudgetOptions: workBudgetOptions ?? GenerousWorkBudget());
private static StreamingWorkBudgetOptions GenerousWorkBudget() => new(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 64 * StreamingWorkBudgetOptions.MiB,
MaxEntityOperations: 1_024,
MaxGpuUploadBytes: 64 * StreamingWorkBudgetOptions.MiB,
MaxGlRetireOperations: 256,
DestinationReserveFraction: 0.75f);
private static IReadOnlyList<LandblockStreamResult> Drain(
Queue<LandblockStreamResult> pending,

View file

@ -238,7 +238,8 @@ public sealed class StreamingControllerReadinessTests
ensureCalls++;
});
controller.Tick(0x12, 0x36);
for (int frame = 0; frame < 32 && ensureCalls == 0; frame++)
controller.Tick(0x12, 0x36);
Assert.Equal(1, ensureCalls);
}
@ -294,7 +295,8 @@ public sealed class StreamingControllerReadinessTests
ensureCalls++;
});
controller.Tick(0x12, 0x36);
for (int frame = 0; frame < 32 && ensureCalls == 0; frame++)
controller.Tick(0x12, 0x36);
Assert.Equal(1, ensureCalls);
Assert.Equal(1, meshes.ReferenceCounts[geometryId]);

View file

@ -337,7 +337,7 @@ public sealed class StreamingFrameControllerTests
}
[Fact]
public void StreamingPublicationMakesSameFrameInboundProjectionResident()
public void CompletedStreamingPublicationMakesInboundProjectionResident()
{
const uint landblock = 0x0A14FFFFu;
const uint cell = 0x0A140001u;
@ -359,7 +359,8 @@ public sealed class StreamingFrameControllerTests
farRadius: 0);
var fixture = new Fixture(streamingBackend: streaming);
fixture.Controller.Tick();
for (int frame = 0; frame < 32 && !state.IsLoaded(landblock); frame++)
fixture.Controller.Tick();
Assert.True(state.IsLoaded(landblock));
var runtime = Runtime(state);

View file

@ -347,8 +347,8 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal(1, first.DeferredCompletions);
Assert.Equal(44, first.DeferredAdoptedCpuBytes);
Assert.True(first.OldestDeferredAgeMilliseconds >= 0);
Assert.Equal(3, first.LastFrame.Operations);
Assert.Equal(3, first.LastFrame.CompletedOperations);
Assert.Equal(6, first.LastFrame.Operations);
Assert.Equal(6, first.LastFrame.CompletedOperations);
Assert.Equal(1, first.LastFrame.YieldCount);
Assert.Equal(88, first.LastFrame.Used.AdoptedCpuBytes);
@ -358,7 +358,7 @@ public sealed class StreamingWorkBudgetTests
Assert.Equal(0, second.DeferredCompletions);
Assert.Equal(0, second.DeferredAdoptedCpuBytes);
Assert.Equal(0, second.OldestDeferredAgeMilliseconds);
Assert.Equal(1, second.LastFrame.CompletedOperations);
Assert.Equal(4, second.LastFrame.CompletedOperations);
}
[Fact]
@ -409,7 +409,7 @@ public sealed class StreamingWorkBudgetTests
}
[Fact]
public void DestinationAndUnloadPriorityNeverBypassExecutionBudgets()
public void DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget()
{
uint center = StreamingRegion.EncodeLandblockIdForTest(32, 32);
uint ordinary = StreamingRegion.EncodeLandblockIdForTest(33, 33);
@ -432,16 +432,16 @@ public sealed class StreamingWorkBudgetTests
controller.Tick(32, 32);
Assert.Equal([center], applied);
Assert.Equal(2, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(1, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.YieldCount);
Assert.Equal(44, controller.WorkDiagnostics.LastFrame.Used.GpuUploadBytes);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
controller.Tick(32, 32);
Assert.Equal([center, ordinary], applied);
Assert.Equal(0, controller.WorkDiagnostics.DeferredCompletions);
Assert.Equal(1, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
Assert.Equal(0, controller.WorkDiagnostics.LastFrame.Used.GlRetireOperations);
}
[Fact]

View file

@ -330,6 +330,65 @@ public class ShadowObjectRegistryTests
entry => entry.EntityId == entityId);
}
[Fact]
public void StreamingReceipts_AreStableOrderedAndAdvanceOneStaticOwner()
{
var reg = new ShadowObjectRegistry();
reg.Register(
9u,
0x01000009u,
new Vector3(12f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
seedCellId: LbId | 1u,
isStatic: true);
reg.Register(
2u,
0x01000002u,
new Vector3(14f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
seedCellId: LbId | 1u,
isStatic: true);
reg.Register(
5u,
0x01000005u,
new Vector3(16f, 12f, 50f),
Quaternion.Identity,
1f,
OffX,
OffY,
LbId,
seedCellId: LbId | 1u,
isStatic: false);
Assert.Equal(
[2u, 9u],
reg.CaptureStaticOwnersForLandblock(LbId));
Assert.Equal(
[2u, 5u, 9u],
reg.CaptureRefloodOwnersForLandblock(LbId));
reg.DeregisterStaticOwnerForLandblock(2u, LbId);
Assert.Equal(2, reg.RetainedRegistrationCount);
Assert.DoesNotContain(
reg.GetObjectsInCell(LbId | 1u),
entry => entry.EntityId == 2u);
Assert.Contains(
reg.GetObjectsInCell(LbId | 1u),
entry => entry.EntityId == 5u);
Assert.Contains(
reg.GetObjectsInCell(LbId | 1u),
entry => entry.EntityId == 9u);
}
[Fact]
public void RefloodLandblock_RestoresAdjacentOwnedFootprintWithdrawnByUnload()
{

View file

@ -372,7 +372,8 @@ public class StreamingControllerTests
System.Array.Empty<uint>());
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(landblockId, LandblockStreamTier.Near, lb, stubMesh));
controller.Tick(50, 50);
for (int frame = 0; frame < 32 && !state.IsLoaded(landblockId); frame++)
controller.Tick(50, 50);
Assert.Single(applied);
Assert.True(state.IsLoaded(landblockId));

View file

@ -136,7 +136,15 @@ public class StreamingControllerTwoTierTests
},
state: state,
nearRadius: 2,
farRadius: 2);
farRadius: 2,
workBudgetOptions: new StreamingWorkBudgetOptions(
MaxUpdateMilliseconds: 100,
MaxCompletionAdmissions: 64,
MaxAdoptedCpuBytes: 16 * StreamingWorkBudgetOptions.MiB,
MaxEntityOperations: 512,
MaxGpuUploadBytes: 16 * StreamingWorkBudgetOptions.MiB,
MaxGlRetireOperations: 128,
DestinationReserveFraction: 0.75f));
ctrl.Tick(50, 50); // drains the Promoted result