diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index fa5e05d2..a3d1df73 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,49 @@ Copy this block when adding a new issue:
---
+## #214 — Dungeon login could replace a complete EnvCell render with a partial second load
+
+**Status:** IN-PROGRESS — architectural fix complete 2026-07-13, pending user visual gate
+**Severity:** HIGH
+**Component:** streaming / EnvCell rendering / portal visibility
+
+**Description:** Logging directly into the starter dungeon could leave its room
+shells untextured or absent after a relaunch even though the same dungeon had
+rendered correctly before logout.
+
+**Root cause:** Two independent load jobs published through shared, process-wide
+pending collections. The login spawn first pre-collapsed streaming onto
+`0x8C04FFFF`, then an obsolete post-spawn `ForceReloadWindow` reset the collapse
+and enqueued the same dungeon again. `EnvCellRenderer.RegisterCell` mutated a
+live landblock's pending lists from the worker while `FinalizeLandblock` drained
+and replaced them on the render thread; the global portal-visibility bag had the
+same ownership flaw across landblocks. The captured failure committed 1,123
+shells, then the duplicate completion replaced them with its remaining 11.
+
+**Resolution:** A worker now builds one private, immutable
+`EnvCellLandblockBuild` per `LandblockBuild`. That exact payload travels with its
+streaming completion and is committed on the render thread to
+`CellVisibility`, `EnvCellRenderer`, and the cell physics cache. There are no
+shared pending cell/shell collections to drain or cross-contaminate. The login
+path now calls `InitializeKnownLoginCenter`: sealed dungeons pre-collapse once;
+outdoor spawns wait for the first correctly centered tick. The old forced reload
+was removed because `StreamingReadinessGate` already prevents guessed-center
+loads before the real player spawn.
+
+**Files:** `src/AcDream.App/Streaming/LandblockBuild.cs`;
+`src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs`;
+`src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs`;
+`src/AcDream.App/Rendering/CellVisibility.cs`;
+`src/AcDream.App/Rendering/GameWindow.cs`.
+
+**Research:** `docs/research/2026-07-13-envcell-landblock-transaction.md`.
+
+**Acceptance:** Log out inside the starter dungeon, relaunch, and enter the same
+character. The dungeon is fully textured on its first frame, remains complete,
+and the log contains exactly one login dungeon collapse with no second reload.
+
+---
+
## #213 — Retail client commands were sent to ACE as chat text
**Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 2a167f0c..7b633f0b 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -394,6 +394,24 @@ bypass; no Scissor over-include). This is a **consolidation of existing machiner
(`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case
gate to mask a seam — that anti-pattern produced the patchwork.
+### Streaming publication ownership
+
+The streaming worker may read dats and build CPU payloads, but it must not mutate
+live render-frame registries. A near-tier job produces one `LandblockBuild` whose
+optional `EnvCellLandblockBuild` owns the complete portal-cell and drawable-shell
+sets for that landblock. The exact build object crosses the `LandblockStreamer`
+completion channel. On the render thread, `StreamingController` applies that one
+completion to `CellVisibility`, cell physics state, `EnvCellRenderer`, and finally
+`GpuWorldState` during the same update-frame drain.
+
+This is the publication boundary: per-job builders are private and single-use;
+completed payloads are immutable snapshots; live landblock stores are replaced as
+complete units. Process-wide pending bags or renderer-owned pending lists are not
+valid streaming seams because a duplicate load or a second landblock can drain or
+replace state produced by another job. CPU mesh extraction may still be scheduled
+onto `ObjectMeshManager`'s thread-safe work queue, but the worker never publishes
+partially hydrated cell membership or shell placement to the renderer.
+
## Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it
diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
index 9483ed79..66850130 100644
--- a/docs/architecture/code-structure.md
+++ b/docs/architecture/code-structure.md
@@ -183,7 +183,7 @@ src/AcDream.App/
│ ├── TextureCache.cs # (already exists)
│ ├── ParticleRenderer.cs # (already exists)
│ ├── Sky/ # (already exists)
-│ ├── Wb/ # (already exists — WB seam)
+│ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction
│ └── Vfx/ # (already exists)
├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
@@ -191,7 +191,7 @@ src/AcDream.App/
│ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
-├── Streaming/ # (already exists)
+├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
├── Input/ # (already exists)
├── Audio/ # (already exists)
└── Plugins/ # (already exists)
diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md
index be58ad63..b45a7b07 100644
--- a/docs/architecture/worldbuilder-inventory.md
+++ b/docs/architecture/worldbuilder-inventory.md
@@ -26,6 +26,15 @@ our tree (see CLAUDE.md for the full breakdown):
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes.
+**EnvCell streaming seam:** `EnvCellLandblockBuildBuilder` is an acdream-owned
+adapter around the extracted WB rendering path. One streaming job privately builds
+the complete portal-cell + shell-placement payload, and `EnvCellRenderer.CommitLandblock`
+publishes that completed snapshot on the render thread. WB's geometry-id arithmetic
+and mesh preparation remain unchanged; the transaction wrapper exists because
+acdream streams asynchronously while the WB editor's manager owned its own loading
+loop. Do not reintroduce worker-side `RegisterCell` calls or shared pending cell
+collections.
+
`DatCollectionAdapter` bridges our `IDatCollection` to the `IDatReaderWriter`
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold).
diff --git a/docs/research/2026-07-13-envcell-landblock-transaction.md b/docs/research/2026-07-13-envcell-landblock-transaction.md
new file mode 100644
index 00000000..90d23f8e
--- /dev/null
+++ b/docs/research/2026-07-13-envcell-landblock-transaction.md
@@ -0,0 +1,106 @@
+# EnvCell landblock transaction — dungeon-login texture loss
+
+## Symptom and captured evidence
+
+Logging the `lundberg` account directly into starter dungeon landblock
+`0x8C04FFFF` produced an untextured/incomplete world after relaunch. The capture
+in `logs/lundberg-dungeon-relaunch-20260713-182018.out.log` established the
+ordering:
+
+```text
+streaming: dungeon collapse -> 0x8C04FFFF
+streaming: dungeon collapse -> 0x8C04FFFF
+[late-register] ... 1123 already committed
+[finalize-replace] ... DISCARDING 1123 committed instances, replacing with 11 pending
+```
+
+The old renderer diagnostics came from commit `0a38d934`. They proved that the
+first job completed a full dungeon and a second job then overwrote it with the
+small tail of a concurrently mutated pending list. This was not a texture decode
+or GPU upload failure: the complete cell-shell set was discarded before draw.
+
+## Root causes
+
+There were two causes, both required for the observed deterministic failure:
+
+1. Commit `9b06a9b8` added a post-login `ForceReloadWindow` to recover from a
+ guessed-center streaming window. Commit `fa9aedca` later added
+ `StreamingReadinessGate`, which prevents that guessed window from being built
+ at all, but the force reload remained. A sealed-dungeon login therefore
+ pre-collapsed once during spawn and collapsed a second time after the forced
+ reset.
+2. The streaming worker published partial state while it was still building.
+ `EnvCellRenderer.RegisterCell` appended to landblock-owned pending lists and a
+ global `_pendingCells` bag collected portal cells. The render thread drained
+ those mutable collections independently. Neither collection identified the
+ streaming job that produced an item, so duplicate and concurrent landblock
+ completions could consume or replace each other's state.
+
+The second cause is the architectural defect. Removing only the duplicate load
+would make the current reproduction rarer, but leave the race available to
+normal unload/reload and promotion timing.
+
+## Retail/WB boundary
+
+No retail gameplay algorithm changes here. The cell portal construction remains
+the existing mechanical port of retail `PView::InitCell @ 0x005A4B70`, including
+the dat `PortalSide` bit and reciprocal portal indexing. Geometry-id arithmetic
+remains the verbatim WorldBuilder `EnvCellRenderManager.cs:94-103` port.
+
+This change is an acdream streaming ownership correction: retail does not expose
+partially hydrated cell arrays to its draw traversal, while acdream must bridge a
+background CPU loader to a single OpenGL/render thread.
+
+## Transaction design
+
+Worker-side pseudocode:
+
+```text
+builder = new EnvCellLandblockBuildBuilder(landblockId)
+for each EnvCell in this one landblock:
+ read dat cell + CellStruct
+ append visibility cell to builder-local storage
+ append drawable shell placement to builder-local storage
+build = builder.Build() // one-way publication; immutable snapshot
+schedule CPU mesh preparation(build) // queue only; no live renderer mutation
+return LandblockBuild(terrain/entities, build)
+```
+
+Render-thread pseudocode:
+
+```text
+completion = streamer.Drain()
+validate completion.EnvCells belongs to completion.Landblock
+replace this landblock's complete CellVisibility set
+cache this landblock's cell physics structs
+replace this landblock's complete EnvCellRenderer snapshot
+publish the LoadedLandblock to GpuWorldState
+```
+
+The invariant is: **one streaming completion owns one complete EnvCell payload;
+only the render thread may publish it to live per-frame state.** A later reload
+may replace an earlier snapshot, but it can only replace it with another complete
+snapshot from that exact job—never a shared-list remainder.
+
+## Login sequencing correction
+
+`StreamingController.InitializeKnownLoginCenter` now encodes the post-readiness-
+gate behavior:
+
+- sealed dungeon: pre-collapse exactly once before the first normal tick;
+- outdoor: enqueue nothing until the first tick at the server-confirmed center.
+
+The obsolete `_pendingForceReloadWindow` path was deleted. This removes the
+duplicate dungeon job without adding a timing guard, retry, or suppression flag.
+
+## Regression coverage
+
+- A 1,123-shell build produces a 1,123-instance committed snapshot.
+- Independently built 1,123- and 11-shell transactions cannot share storage.
+- A builder can publish only once.
+- Visibility replacement affects only the matching landblock and rejects a
+ foreign cell before mutating existing state.
+- `LandblockStreamer` carries the exact `LandblockBuild`/EnvCell transaction
+ instance from worker to consumer.
+- Repeated sealed-dungeon login initialization enqueues one collapse/load; an
+ outdoor login waits for its first correctly centered tick.
diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index 7cabee61..035016b0 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -263,6 +263,32 @@ public sealed class CellVisibility
_cellLookup[cell.CellId] = cell;
}
+ ///
+ /// Atomically replaces one landblock's complete portal-cell set on the
+ /// render thread. Streaming workers build the input privately; no partially
+ /// hydrated landblock is ever visible to the per-frame flood.
+ ///
+ /// Full landblock id, e.g. 0xA9B4FFFF.
+ public void CommitLandblock(uint landblockId, IReadOnlyList cells)
+ {
+ uint prefix = landblockId >> 16;
+ if (cells.Any(cell => (cell.CellId >> 16) != prefix))
+ throw new ArgumentException(
+ "A visibility cell belongs to a different landblock.",
+ nameof(cells));
+
+ if (_cellsByLandblock.TryGetValue(prefix, out var previous))
+ {
+ foreach (var cell in previous)
+ _cellLookup.Remove(cell.CellId);
+ }
+
+ var committed = new List(cells);
+ _cellsByLandblock[prefix] = committed;
+ foreach (var cell in committed)
+ _cellLookup[cell.CellId] = cell;
+ }
+
///
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
/// landblock prefix. Used by GameWindow.ApplyLoadedTerrainLocked when
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 27424c48..53824db3 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -191,19 +191,6 @@ public sealed class GameWindow : IDisposable
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary? _surfaceCache;
- // Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
- // (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
- // this dict and uploads them via EnsureUploaded before the per-MeshRef loop.
- // ConcurrentDictionary is required because the worker and render threads
- // access this simultaneously without a broader lock.
- private readonly System.Collections.Concurrent.ConcurrentDictionary<
- uint, System.Collections.Generic.IReadOnlyList>
- _pendingCellMeshes = new();
-
- // Step 4: pending LoadedCell objects built on the worker thread, drained
- // to _cellVisibility on the render thread in ApplyLoadedTerrain.
- private readonly System.Collections.Concurrent.ConcurrentBag _pendingCells = new();
-
// Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame
@@ -991,17 +978,6 @@ public sealed class GameWindow : IDisposable
// closes (stale-centered geometry built during the InWorld-but-not-yet-
// located window, applied late once its build finished).
private bool _liveCenterKnown;
- // Set by the login-spawn recenter (network thread) when the spawn landblock
- // differs from the startup streaming center; consumed on the render thread
- // in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far
- // login-spawn moves the render origin just like an outdoor teleport, so the
- // streaming region — bootstrapped around the stale startup center — must be
- // rebuilt fresh around the spawn. Without this, RecenterTo treats the
- // old/new window overlap as already-resident (MarkResidentFromBootstrap marks
- // residence before the async loads land), leaving a permanent hole of
- // resident-but-never-loaded landblocks where the player spawns — the
- // cold-spawn "invisible player / world not loading" bug (2026-07-03).
- private volatile bool _pendingForceReloadWindow;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
@@ -3182,13 +3158,6 @@ public sealed class GameWindow : IDisposable
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX;
_liveCenterY = lbY;
- // The origin jumped — the streaming region is still bootstrapped
- // around the stale startup center with residence marked before its
- // async loads landed. Flag a full window rebuild (consumed on the
- // render thread in OnUpdateFrame) so the region re-bootstraps fresh
- // around the spawn, re-loading the landblocks RecenterTo would
- // otherwise skip as already-resident — the cold-spawn streaming hole.
- _pendingForceReloadWindow = true;
}
// #135: the instant we know the player spawned into a SEALED dungeon,
@@ -3202,10 +3171,12 @@ public sealed class GameWindow : IDisposable
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid
- && _streamingController is not null
- && IsSealedDungeonCell(p.LandblockId))
+ && _streamingController is not null)
{
- _streamingController.PreCollapseToDungeon(lbX, lbY);
+ _streamingController.InitializeKnownLoginCenter(
+ lbX,
+ lbY,
+ isSealedDungeon: IsSealedDungeonCell(p.LandblockId));
}
var origin = new System.Numerics.Vector3(
@@ -6453,7 +6424,7 @@ public sealed class GameWindow : IDisposable
/// with an
/// early-out at the source.
///
- private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(
+ private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
@@ -6491,7 +6462,7 @@ public sealed class GameWindow : IDisposable
}
}
- private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(
+ private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
@@ -6506,11 +6477,12 @@ public sealed class GameWindow : IDisposable
{
var heightmapOnly = _dats.Get(landblockId);
if (heightmapOnly is null) return null;
- return new AcDream.Core.World.LoadedLandblock(
- landblockId,
- heightmapOnly,
- System.Array.Empty(),
- AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities
+ return new AcDream.App.Streaming.LandblockBuild(
+ new AcDream.Core.World.LoadedLandblock(
+ landblockId,
+ heightmapOnly,
+ System.Array.Empty(),
+ AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities
}
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
@@ -6587,17 +6559,27 @@ public sealed class GameWindow : IDisposable
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
- merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
+ var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
+ merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, envCellBuild));
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
// `merged` so entity GfxObj/Setup ids are known.
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
- return new AcDream.Core.World.LoadedLandblock(
- baseLoaded.LandblockId,
- baseLoaded.Heightmap,
- merged,
- physicsDats);
+ var completedEnvCells = envCellBuild.Build();
+ if (_wbMeshAdapter?.MeshManager is { } meshManager)
+ {
+ AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
+ completedEnvCells,
+ meshManager);
+ }
+ return new AcDream.App.Streaming.LandblockBuild(
+ new AcDream.Core.World.LoadedLandblock(
+ baseLoaded.LandblockId,
+ baseLoaded.Heightmap,
+ merged,
+ physicsDats),
+ completedEnvCells);
}
///
@@ -6898,13 +6880,18 @@ public sealed class GameWindow : IDisposable
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
- /// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
- /// so ApplyLoadedTerrain can drain + upload them on the render thread.
+ /// Portal cells and drawable shell placements are accumulated in the
+ /// transaction-local . The render thread
+ /// cannot observe this landblock until the streaming completion carries the
+ /// finished transaction to ApplyLoadedTerrain.
///
/// Ported from pre-streaming preload lines 407-565.
///
private List BuildInteriorEntitiesForStreaming(
- uint landblockId, int lbX, int lbY)
+ uint landblockId,
+ int lbX,
+ int lbY,
+ AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
{
var result = new List();
if (_dats is null) return result;
@@ -6976,12 +6963,12 @@ public sealed class GameWindow : IDisposable
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
- // WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
- // _pendingCellMeshes (still populated for any consumer) and the physics
- // data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes).
+ // WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
+ // drawable-geometry predicate; the actual shell placement is now owned
+ // by this streaming job's EnvCellLandblockBuild transaction.
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work
- // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
+ // fine; EnvCellRenderer receives only the completed shell transaction.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
@@ -7007,44 +6994,20 @@ public sealed class GameWindow : IDisposable
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
- // before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
+ // before the flood runs; the cell-build transaction reads portals, NOT
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
// is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
- BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
-
- // PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
- // drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
- // sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
- // TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
- // never reach it and lags one cell behind the eye (#133 residual: the camera sat
- // 1.32 m past the ramp portal's plane while the viewer cell stalled in
- // 0x00070103 — the sweep transited every cached neighbour but NEVER the
- // un-cached connector 0x014D — so the side test culled the on-screen connector
- // portal and the grey clear showed through). Retail keeps the whole landblock
- // cell array resident for the sweep; a portals-only connector has an empty
- // collision BSP but its portals drive the transit. CacheCellStruct reads the
- // cellStruct directly, not the render sub-meshes.
- _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
-
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
- if (cellSubMeshes.Count > 0)
- {
- _pendingCellMeshes[envCellId] = cellSubMeshes;
-
- // Phase A8: register the cell with EnvCellRenderer for rendering.
- // staticObjects is empty — cell stabs continue as separate WorldEntity
- // records via the dispatcher (see lines below for the unchanged stab path).
- _envCellRenderer?.RegisterCell(
- landblockId: landblockId,
- envCellId: envCellId,
- envCell: envCell,
- cellStruct: cellStruct,
- cellTransform: cellTransform,
- cellWorldPosition: cellOrigin,
- cellRotation: envCell.Position.Orientation,
- staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
- }
+ envCellBuild.AddCell(
+ envCellId,
+ envCell,
+ cellStruct,
+ physicsCellOrigin,
+ physicsCellTransform,
+ cellOrigin,
+ cellTransform,
+ hasDrawableGeometry: cellSubMeshes.Count > 0);
}
}
@@ -7207,9 +7170,10 @@ public sealed class GameWindow : IDisposable
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread.
///
- private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb,
+ private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
+ var lb = build.Landblock;
if (_terrain is null || _dats is null) return;
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
@@ -7227,7 +7191,7 @@ public sealed class GameWindow : IDisposable
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (_frameDiag)
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
- ApplyLoadedTerrainLocked(lb, meshData);
+ ApplyLoadedTerrainLocked(build, meshData);
if (_frameDiag)
{
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
@@ -7235,164 +7199,11 @@ public sealed class GameWindow : IDisposable
}
}
- ///
- /// Step 4: build a for portal visibility and queue it
- /// for render-thread registration. Called from the worker thread during
- /// .
- ///
- private void BuildLoadedCell(
- uint envCellId,
- DatReaderWriter.DBObjs.EnvCell envCell,
- DatReaderWriter.Types.CellStruct cellStruct,
- System.Numerics.Vector3 cellOrigin,
- System.Numerics.Matrix4x4 cellTransform)
- {
- System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse);
-
- // Compute local AABB from CellStruct vertices.
- var boundsMin = new System.Numerics.Vector3(float.MaxValue);
- var boundsMax = new System.Numerics.Vector3(float.MinValue);
- foreach (var kvp in cellStruct.VertexArray.Vertices)
- {
- var v = kvp.Value;
- var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
- boundsMin = System.Numerics.Vector3.Min(boundsMin, pos);
- boundsMax = System.Numerics.Vector3.Max(boundsMax, pos);
- }
- if (boundsMin.X == float.MaxValue)
- {
- boundsMin = System.Numerics.Vector3.Zero;
- boundsMax = System.Numerics.Vector3.Zero;
- }
-
- // Build portal list and clip planes from CellPortals.
- var portals = new List();
- var clipPlanes = new List();
- var portalPolygons = new List();
-
- foreach (var portal in envCell.CellPortals)
- {
- portals.Add(new CellPortalInfo(
- portal.OtherCellId,
- portal.PolygonId,
- (ushort)portal.Flags,
- portal.OtherPortalId)); // Phase U.2b: dat back-link → reciprocal portal index (retail 433557)
-
- // Build clip plane from the portal polygon.
- if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
- && poly.VertexIds.Count >= 3)
- {
- // Get first 3 vertices in local space for the plane.
- System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero,
- p1 = System.Numerics.Vector3.Zero,
- p2 = System.Numerics.Vector3.Zero;
- bool found = true;
- if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
- p0 = new System.Numerics.Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
- else found = false;
- if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
- p1 = new System.Numerics.Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
- else found = false;
- if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
- p2 = new System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
- else found = false;
-
- if (found)
- {
- var normal = System.Numerics.Vector3.Normalize(
- System.Numerics.Vector3.Cross(p1 - p0, p2 - p0));
- float d = -System.Numerics.Vector3.Dot(normal, p0);
-
- // InsideSide from the dat PortalSide bit — retail PView::InitCell
- // (0x005a4b70) gates traversal on `iVar9 != portals->portal_side`, and
- // acdream's own physics path uses the same bit (CellTransit.cs:190).
- // The former AABB-centroid reconstruction mis-derived the interior side
- // for THIN connector cells: the connector's bounding-box center falls on
- // the wrong side of a portal near the cell edge, so the eye read as a
- // back-portal and the forward room was culled — the #186 grey flap at
- // 0xF6820118→0116 (retail draws 0116 from that root; cdb-confirmed).
- // PortalSide (Flags&2)==0 → the portal polygon normal points INTO the
- // owning cell → interior is the negative half → InsideSide=1. Diagnostic
- // Issue186…PortalSide_CentroidVsDatBit_AtGreyEye: the dat bit matches the
- // centroid on every portal of these cells EXCEPT the one #186 breaks.
- int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0;
-
- clipPlanes.Add(new PortalClipPlane
- {
- Normal = normal,
- D = d,
- InsideSide = insideSide,
- });
- }
- else
- {
- clipPlanes.Add(default);
- }
- }
- else
- {
- clipPlanes.Add(default);
- }
-
- // Phase A8: capture the full polygon vertices in cell-local space
- // for the indoor stencil pipeline. Reads the same source as the
- // ClipPlane block above (polygon.VertexIds → cellStruct vertices).
- System.Numerics.Vector3[] polyVerts = System.Array.Empty();
- if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
- && portalPoly.VertexIds.Count >= 3)
- {
- polyVerts = new System.Numerics.Vector3[portalPoly.VertexIds.Count];
- bool allResolved = true;
- for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
- {
- if (cellStruct.VertexArray.Vertices.TryGetValue(
- (ushort)portalPoly.VertexIds[vi], out var pv))
- {
- polyVerts[vi] = new System.Numerics.Vector3(
- pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
- }
- else
- {
- allResolved = false;
- break;
- }
- }
- if (!allResolved)
- polyVerts = System.Array.Empty();
- }
- portalPolygons.Add(polyVerts);
- }
-
- // Phase U.4c: surface the stable PVS + seen-outside flag onto the render cell.
- // Both come straight off the dat EnvCell — no new parsing (PhysicsDataCache
- // already reads VisibleCells the same way; A8CellAudit reads the flag).
- uint lbPrefix = envCellId & 0xFFFF0000u;
- var visibleCells = new List();
- if (envCell.VisibleCells is not null)
- foreach (var lowId in envCell.VisibleCells)
- visibleCells.Add(lbPrefix | lowId);
- bool seenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
-
- var loaded = new LoadedCell
- {
- CellId = envCellId,
- WorldPosition = cellOrigin,
- WorldTransform = cellTransform,
- InverseWorldTransform = inverse,
- LocalBoundsMin = boundsMin,
- LocalBoundsMax = boundsMax,
- Portals = portals,
- ClipPlanes = clipPlanes,
- PortalPolygons = portalPolygons, // Phase A8
- VisibleCells = visibleCells, // Phase U.4c
- SeenOutside = seenOutside, // Phase U.4c
- };
- _pendingCells.Add(loaded);
- }
-
- private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb,
+ private void ApplyLoadedTerrainLocked(
+ AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
+ var lb = build.Landblock;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return;
@@ -7425,13 +7236,20 @@ public sealed class GameWindow : IDisposable
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
- // Step 4: drain pending LoadedCells from the worker thread.
- // Also collect into a local dict for the BuildingLoader stamping pass below.
- var drainedCells = new System.Collections.Generic.Dictionary();
- while (_pendingCells.TryTake(out var cell))
+ // Commit the exact visibility-cell snapshot produced by THIS streaming
+ // completion. The former global ConcurrentBag let one landblock drain
+ // another landblock's cells and exposed partial builds to the flood.
+ var committedCells = new System.Collections.Generic.Dictionary();
+ if (build.EnvCells is { } envCellBuild)
{
- _cellVisibility.AddCell(cell);
- drainedCells[cell.CellId] = cell;
+ if (envCellBuild.LandblockId != lb.LandblockId)
+ throw new InvalidOperationException(
+ $"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " +
+ $"landblock 0x{lb.LandblockId:X8}.");
+
+ _cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells);
+ foreach (var cell in envCellBuild.VisibilityCells)
+ committedCells[cell.CellId] = cell;
}
// Compute the per-landblock AABB for frustum culling. XY from the
@@ -7493,6 +7311,19 @@ public sealed class GameWindow : IDisposable
// Transform CellStruct vertices from cell-local to world space.
var rot = envCell.Position.Orientation;
var cellOriginWorld = envCell.Position.Origin + origin;
+ var physicsCellTransform =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(rot) *
+ System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld);
+
+ // Commit live physics-cache state on the render thread with
+ // the same completed landblock transaction. The worker only
+ // builds private output; it no longer mutates cell registries.
+ _physicsDataCache.CacheCellStruct(
+ envCellId,
+ envCell,
+ cellStruct,
+ physicsCellTransform);
+
var worldVerts = new Dictionary(
cellStruct.VertexArray.Vertices.Count);
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
@@ -7655,16 +7486,9 @@ public sealed class GameWindow : IDisposable
// surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
if (lbInfo is not null)
{
- // Merge: previously-loaded cells + freshly-drained cells.
- // drainedCells wins on key collision (it has the same instance
- // anyway — both dicts reference the same LoadedCell objects).
- var lbStampCells = new System.Collections.Generic.Dictionary(drainedCells);
- uint lbPrefix = (lb.LandblockId >> 16) & 0xFFFFu;
- foreach (var c in _cellVisibility.GetCellsForLandblock(lbPrefix))
- {
- if (!lbStampCells.ContainsKey(c.CellId))
- lbStampCells[c.CellId] = c;
- }
+ // The streaming result now carries the complete landblock cell
+ // set; no cross-frame merge or global-bag recovery is required.
+ var lbStampCells = committedCells;
// FIX 2026-05-28: normalize storage key to the cell-prefix
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
@@ -7681,15 +7505,13 @@ public sealed class GameWindow : IDisposable
lbInfo, lb.LandblockId, lbStampCells);
}
- // Phase A8: finalize EnvCellRenderer's per-landblock instance store.
- // Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds,
- // populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
- _envCellRenderer?.FinalizeLandblock(lb.LandblockId);
+ if (build.EnvCells is { } renderBuild)
+ _envCellRenderer?.CommitLandblock(renderBuild);
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
- // (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
+ // and the legacy cell-mesh upload path are retired with InstancedMeshRenderer.
// Cache GfxObj physics data (BSP trees) for the physics engine — this
// loop is physics-only, not renderer-side.
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
@@ -7703,10 +7525,6 @@ public sealed class GameWindow : IDisposable
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
}
- // Drain _pendingCellMeshes to prevent unbounded accumulation.
- // The data is no longer consumed (WB handles EnvCell geometry through
- // its own pipeline), but the worker thread still populates this dict.
- _pendingCellMeshes.Clear();
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
@@ -8202,17 +8020,6 @@ public sealed class GameWindow : IDisposable
observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 0xFFu);
}
- // Consume the login-spawn far-recenter flag (network thread → render
- // thread): drop the stale startup window + null the region so this
- // Tick re-bootstraps the whole window fresh around the spawn origin.
- // Same mechanism the outdoor-teleport path uses (line ~5725). Fixes
- // the cold-spawn streaming hole (resident-but-never-loaded landblocks
- // the RecenterTo diff skipped as already-resident).
- if (_pendingForceReloadWindow)
- {
- _pendingForceReloadWindow = false;
- _streamingController.ForceReloadWindow();
- }
_streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
@@ -13100,7 +12907,7 @@ public sealed class GameWindow : IDisposable
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
- // the hydration path (BuildLoadedCell, ~line 5999) and as the physics
+ // the hydration path (EnvCellLandblockBuildBuilder) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a
diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
index 38f263b8..c1dc2d85 100644
--- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
+++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
@@ -188,7 +188,7 @@ public static class PortalVisibilityBuilder
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
- // polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
+ // polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
new file mode 100644
index 00000000..36d3878a
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs
@@ -0,0 +1,299 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// One drawable EnvCell shell prepared by the streaming worker. This is CPU-only
+/// placement data; the render thread schedules mesh preparation when it commits
+/// the containing .
+///
+public sealed record EnvCellShellPlacement(
+ uint CellId,
+ ulong GeometryId,
+ uint EnvironmentId,
+ ushort CellStructure,
+ ImmutableArray Surfaces,
+ Vector3 WorldPosition,
+ Quaternion Rotation,
+ Matrix4x4 Transform,
+ WbBoundingBox LocalBounds,
+ WbBoundingBox WorldBounds);
+
+///
+/// Complete, immutable worker output for one landblock's indoor cells. It owns
+/// both portal-visibility cells and drawable shell placements so neither can be
+/// drained by, or mixed with, another streaming completion.
+///
+public sealed class EnvCellLandblockBuild
+{
+ public EnvCellLandblockBuild(
+ uint landblockId,
+ IEnumerable visibilityCells,
+ IEnumerable shells)
+ {
+ LandblockId = landblockId;
+ VisibilityCells = visibilityCells.ToImmutableArray();
+ Shells = shells.ToImmutableArray();
+
+ uint expectedPrefix = landblockId & 0xFFFF0000u;
+ if (VisibilityCells.Any(cell => (cell.CellId & 0xFFFF0000u) != expectedPrefix))
+ throw new ArgumentException("A visibility cell belongs to a different landblock.", nameof(visibilityCells));
+ if (Shells.Any(shell => (shell.CellId & 0xFFFF0000u) != expectedPrefix))
+ throw new ArgumentException("A render shell belongs to a different landblock.", nameof(shells));
+ }
+
+ public uint LandblockId { get; }
+ public ImmutableArray VisibilityCells { get; }
+ public ImmutableArray Shells { get; }
+}
+
+///
+/// Transaction-local builder used only by one streaming job. Unlike the former
+/// global pending bags, instances of this class are never shared between jobs or
+/// observed by the render thread before returns.
+///
+public sealed class EnvCellLandblockBuildBuilder
+{
+ private readonly uint _landblockId;
+ private readonly List _visibilityCells = new();
+ private readonly List _shells = new();
+ private bool _built;
+
+ public EnvCellLandblockBuildBuilder(uint landblockId)
+ {
+ _landblockId = landblockId;
+ }
+
+ public void AddCell(
+ uint envCellId,
+ EnvCell envCell,
+ CellStruct cellStruct,
+ Vector3 physicsCellOrigin,
+ Matrix4x4 physicsCellTransform,
+ Vector3 shellWorldPosition,
+ Matrix4x4 shellTransform,
+ bool hasDrawableGeometry)
+ {
+ if (_built)
+ throw new InvalidOperationException("This landblock cell build is already complete.");
+
+ var localBounds = ComputeLocalBounds(cellStruct);
+ _visibilityCells.Add(BuildVisibilityCell(
+ envCellId,
+ envCell,
+ cellStruct,
+ physicsCellOrigin,
+ physicsCellTransform,
+ localBounds));
+
+ if (!hasDrawableGeometry)
+ return;
+
+ ulong geometryId = ComputeGeometryId(
+ envCell.EnvironmentId,
+ envCell.CellStructure,
+ envCell.Surfaces);
+ _shells.Add(new EnvCellShellPlacement(
+ envCellId,
+ geometryId,
+ envCell.EnvironmentId,
+ envCell.CellStructure,
+ envCell.Surfaces.ToImmutableArray(),
+ shellWorldPosition,
+ envCell.Position.Orientation,
+ shellTransform,
+ localBounds,
+ TransformBoundingBox(localBounds, shellTransform)));
+ }
+
+ public EnvCellLandblockBuild Build()
+ {
+ if (_built)
+ throw new InvalidOperationException("This landblock cell build is already complete.");
+ _built = true;
+ return new EnvCellLandblockBuild(_landblockId, _visibilityCells, _shells);
+ }
+
+ ///
+ /// Source: WB EnvCellRenderManager.cs:94-103 (verbatim arithmetic).
+ ///
+ public static ulong ComputeGeometryId(
+ uint environmentId,
+ ushort cellStructure,
+ IReadOnlyList surfaces)
+ {
+ var hash = 17L;
+ hash = hash * 31 + (int)environmentId;
+ hash = hash * 31 + cellStructure;
+ foreach (var surface in surfaces)
+ hash = hash * 31 + surface;
+ return (ulong)hash | 0x2_0000_0000UL;
+ }
+
+ private static WbBoundingBox ComputeLocalBounds(CellStruct cellStruct)
+ {
+ var min = new Vector3(float.MaxValue);
+ var max = new Vector3(float.MinValue);
+ foreach (var vertex in cellStruct.VertexArray.Vertices.Values)
+ {
+ var position = new Vector3(vertex.Origin.X, vertex.Origin.Y, vertex.Origin.Z);
+ min = Vector3.Min(min, position);
+ max = Vector3.Max(max, position);
+ }
+
+ return min.X == float.MaxValue
+ ? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
+ : new WbBoundingBox(min, max);
+ }
+
+ private static WbBoundingBox TransformBoundingBox(WbBoundingBox box, Matrix4x4 transform)
+ {
+ Span corners = stackalloc Vector3[8]
+ {
+ new(box.Min.X, box.Min.Y, box.Min.Z),
+ new(box.Max.X, box.Min.Y, box.Min.Z),
+ new(box.Min.X, box.Max.Y, box.Min.Z),
+ new(box.Max.X, box.Max.Y, box.Min.Z),
+ new(box.Min.X, box.Min.Y, box.Max.Z),
+ new(box.Max.X, box.Min.Y, box.Max.Z),
+ new(box.Min.X, box.Max.Y, box.Max.Z),
+ new(box.Max.X, box.Max.Y, box.Max.Z),
+ };
+
+ var min = new Vector3(float.MaxValue);
+ var max = new Vector3(float.MinValue);
+ foreach (var corner in corners)
+ {
+ var transformed = Vector3.Transform(corner, transform);
+ min = Vector3.Min(min, transformed);
+ max = Vector3.Max(max, transformed);
+ }
+
+ return new WbBoundingBox(min, max);
+ }
+
+ ///
+ /// Mechanical extraction of the former GameWindow.BuildLoadedCell. Portal-side behavior
+ /// is unchanged: retail PView::InitCell (0x005a4b70) uses the dat PortalSide
+ /// bit, and the reciprocal portal index follows the named retail path at
+ /// pseudo-C line 433557.
+ ///
+ private static LoadedCell BuildVisibilityCell(
+ uint envCellId,
+ EnvCell envCell,
+ CellStruct cellStruct,
+ Vector3 cellOrigin,
+ Matrix4x4 cellTransform,
+ WbBoundingBox localBounds)
+ {
+ Matrix4x4.Invert(cellTransform, out var inverse);
+
+ var portals = new List();
+ var clipPlanes = new List();
+ var portalPolygons = new List();
+
+ foreach (var portal in envCell.CellPortals)
+ {
+ portals.Add(new CellPortalInfo(
+ portal.OtherCellId,
+ portal.PolygonId,
+ (ushort)portal.Flags,
+ portal.OtherPortalId));
+
+ if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
+ && poly.VertexIds.Count >= 3)
+ {
+ Vector3 p0 = Vector3.Zero, p1 = Vector3.Zero, p2 = Vector3.Zero;
+ bool found = true;
+ if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
+ p0 = new Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
+ else
+ found = false;
+ if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
+ p1 = new Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
+ else
+ found = false;
+ if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
+ p2 = new Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
+ else
+ found = false;
+
+ if (found)
+ {
+ var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
+ float d = -Vector3.Dot(normal, p0);
+ int insideSide = ((ushort)portal.Flags & 0x2) == 0 ? 1 : 0;
+ clipPlanes.Add(new PortalClipPlane
+ {
+ Normal = normal,
+ D = d,
+ InsideSide = insideSide,
+ });
+ }
+ else
+ {
+ clipPlanes.Add(default);
+ }
+ }
+ else
+ {
+ clipPlanes.Add(default);
+ }
+
+ Vector3[] polygonVertices = Array.Empty();
+ if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPolygon)
+ && portalPolygon.VertexIds.Count >= 3)
+ {
+ polygonVertices = new Vector3[portalPolygon.VertexIds.Count];
+ bool allResolved = true;
+ for (int vertexIndex = 0; vertexIndex < portalPolygon.VertexIds.Count; vertexIndex++)
+ {
+ if (cellStruct.VertexArray.Vertices.TryGetValue(
+ (ushort)portalPolygon.VertexIds[vertexIndex], out var vertex))
+ {
+ polygonVertices[vertexIndex] = new Vector3(
+ vertex.Origin.X,
+ vertex.Origin.Y,
+ vertex.Origin.Z);
+ }
+ else
+ {
+ allResolved = false;
+ break;
+ }
+ }
+
+ if (!allResolved)
+ polygonVertices = Array.Empty();
+ }
+ portalPolygons.Add(polygonVertices);
+ }
+
+ uint landblockPrefix = envCellId & 0xFFFF0000u;
+ var visibleCells = new List();
+ if (envCell.VisibleCells is not null)
+ {
+ foreach (var lowId in envCell.VisibleCells)
+ visibleCells.Add(landblockPrefix | lowId);
+ }
+
+ return new LoadedCell
+ {
+ CellId = envCellId,
+ WorldPosition = cellOrigin,
+ WorldTransform = cellTransform,
+ InverseWorldTransform = inverse,
+ LocalBoundsMin = localBounds.Min,
+ LocalBoundsMax = localBounds.Max,
+ Portals = portals,
+ ClipPlanes = clipPlanes,
+ PortalPolygons = portalPolygons,
+ VisibleCells = visibleCells,
+ SeenOutside = envCell.Flags.HasFlag(EnvCellFlags.SeenOutside),
+ };
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs
new file mode 100644
index 00000000..f8fe92e1
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/EnvCellMeshPreparationScheduler.cs
@@ -0,0 +1,23 @@
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Starts CPU mesh extraction for a completed EnvCell build without publishing
+/// that build to the live renderer. ObjectMeshManager owns its thread-safe work
+/// queue; the render-thread commit remains a small atomic state replacement.
+///
+public static class EnvCellMeshPreparationScheduler
+{
+ public static void Schedule(
+ EnvCellLandblockBuild build,
+ ObjectMeshManager meshManager)
+ {
+ foreach (var shell in build.Shells)
+ {
+ _ = meshManager.PrepareEnvCellGeomMeshDataAsync(
+ shell.GeometryId,
+ shell.EnvironmentId,
+ shell.CellStructure,
+ new List(shell.Surfaces));
+ }
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index e8d454a4..6be8d96d 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -8,15 +8,14 @@
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
-// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
//
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
// class owns the landblock-streaming loop (Update, _pendingGeneration,
// _uploadQueue). acdream's StreamingController already does that work —
-// running a parallel loop would compete for dat I/O. Instead, we expose
-// RegisterCell(...) as the seam: callers populate our instance store at
-// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
+// running a parallel loop would compete for dat I/O. Instead, streaming builds
+// a private EnvCellLandblockBuild and CommitLandblock publishes the completed
+// snapshot on the render thread.
using System;
using System.Collections.Concurrent;
@@ -36,7 +35,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
- // Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
+ // Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF).
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary _landblocks —
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
private readonly ConcurrentDictionary _landblocks = new();
@@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
///
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List surfaces)
- {
- // WB EnvCellRenderManager.cs:95-102 verbatim:
- var hash = 17L;
- hash = hash * 31 + (int)environmentId;
- hash = hash * 31 + cellStructure;
- foreach (var surface in surfaces) {
- hash = hash * 31 + surface;
- }
- // Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry)
- return (ulong)hash | 0x2_0000_0000UL;
- }
+ => EnvCellLandblockBuildBuilder.ComputeGeometryId(
+ environmentId,
+ cellStructure,
+ surfaces);
// ---------------------------------------------------------------------------
- // RegisterCell — streaming seam
- // Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
- // time, ONCE per EnvCell that has non-null EnvironmentId.
+ // CommitLandblock — render-thread transaction boundary
// ---------------------------------------------------------------------------
///
- /// Registers a single EnvCell and its static objects into the per-landblock
- /// pending list. Call after all cells for a
- /// landblock have been registered.
+ /// Commits one complete worker-built landblock snapshot. A replacement is
+ /// constructed off to the side, then published with one dictionary write;
+ /// render preparation can observe either the previous complete snapshot or
+ /// this complete snapshot, never an in-progress cell list.
///
- public void RegisterCell(
- uint landblockId,
- uint envCellId,
- DatReaderWriter.DBObjs.EnvCell envCell,
- DatReaderWriter.Types.CellStruct cellStruct,
- Matrix4x4 cellTransform,
- Vector3 cellWorldPosition,
- Quaternion cellRotation,
- IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
+ public void CommitLandblock(EnvCellLandblockBuild build)
{
- // 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
- var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
+ _landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
+ NeedsPrepare = true;
+ }
- // 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
- // This populates ObjectMeshManager._renderData[cellGeomId] when complete.
- // Verified Phase W Stage 4 (T4.4): ceilings are present by construction.
- // PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons
- // (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO
- // surface filter — every polygon that passes the ≥3-vertex check is added to
- // the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which
- // is the same closed-box structure. No ceiling polygons are dropped.
- _ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
- envCell.CellStructure, envCell.Surfaces);
-
- // 3. Compute local bounds from cellStruct vertex array.
- var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
- var worldBounds = TransformBoundingBox(localBounds, cellTransform);
-
- // 4. Create the per-cell SceneryInstance.
- var cellInstance = new EnvCellSceneryInstance
+ ///
+ /// Pure half of . Kept separate so transaction
+ /// replacement semantics are regression-tested without an OpenGL context.
+ ///
+ internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build)
+ {
+ var replacement = new EnvCellLandblock
{
- ObjectId = cellGeomId,
- InstanceId = envCellId, // uint InstanceId maps to the cell id
- IsBuilding = true,
- IsEntryCell = false, // could be derived from entry-portal walk; default false
- WorldPosition = cellWorldPosition,
- LocalPosition = Vector3.Zero,
- Rotation = cellRotation,
- Scale = Vector3.One,
- Transform = cellTransform,
- LocalBoundingBox = localBounds,
- BoundingBox = worldBounds,
+ GridX = (int)((build.LandblockId >> 24) & 0xFFu),
+ GridY = (int)((build.LandblockId >> 16) & 0xFFu),
};
- var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
+ foreach (var shell in build.Shells)
{
- GridX = (int)((id >> 24) & 0xFFu),
- GridY = (int)((id >> 16) & 0xFFu),
- });
-
- lock (lb.Lock)
- {
- // TEMP diagnostic #105 (strip with fix): a registration landing AFTER
- // this landblock was already finalized starts a fresh pending list that
- // only commits if ANOTHER finalize arrives — and that one will REPLACE
- // (not merge) the committed set. One-shot per landblock per pending list.
- if (lb.InstancesReady && lb.PendingInstances is null)
- Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)");
- lb.PendingInstances ??= new List(capacity: 32);
- lb.PendingInstances.Add(cellInstance);
- lb.PendingEnvCellBounds ??= new Dictionary();
- lb.PendingEnvCellBounds[envCellId] = worldBounds;
-
- // Add static-object instances inside the cell.
- foreach (var stab in staticObjects)
+ replacement.Instances.Add(new EnvCellSceneryInstance
{
- // Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
- _ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
+ 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;
- WbBoundingBox stabBoundsLocal;
- var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup);
- if (rawBounds.HasValue)
- stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max);
- else
- stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero);
-
- var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
-
- lb.PendingInstances.Add(new EnvCellSceneryInstance
- {
- ObjectId = stab.StaticObjectId,
- InstanceId = 0,
- IsSetup = stab.IsSetup,
- IsBuilding = false,
- Transform = stab.Transform,
- BoundingBox = stabBoundsWorld,
- LocalBoundingBox = stabBoundsLocal,
- Scale = Vector3.One,
- Rotation = stab.Rot,
- // CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups
- CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch
- });
-
- // Union the cell bounds with the stab bounds.
- var current = lb.PendingEnvCellBounds[envCellId];
- lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
+ if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
+ {
+ instances = new List();
+ replacement.BuildingPartGroups[shell.GeometryId] = instances;
}
+ instances.Add(new InstanceData
+ {
+ Transform = shell.Transform,
+ CellId = shell.CellId,
+ Flags = 0,
+ });
}
- }
- ///
- /// Atomically promotes the pending instance list to the committed list,
- /// computes total EnvCell bounds, populates PartGroups, and marks the
- /// landblock ready for rendering.
- ///
- public void FinalizeLandblock(uint landblockId)
- {
- if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
- lock (lb.Lock)
- {
- if (lb.PendingInstances is not null)
- {
- // TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a
- // previous finalize already committed instances for this landblock,
- // this swap DISCARDS them in favor of the new pending set. A partial
- // pending set (finalize racing a still-registering promote job)
- // silently loses buildings.
- if (lb.Instances is { Count: > 0 })
- Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending");
- lb.Instances = lb.PendingInstances;
- lb.PendingInstances = null;
- }
- if (lb.PendingEnvCellBounds is not null)
- {
- lb.EnvCellBounds = lb.PendingEnvCellBounds;
- lb.PendingEnvCellBounds = null;
- }
+ var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
+ foreach (var bounds in replacement.EnvCellBounds.Values)
+ total = WbBoundingBox.Union(total, bounds);
+ replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0
+ ? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
+ : total;
- // Compute total bounds union across all cells.
- var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
- foreach (var b in lb.EnvCellBounds.Values)
- total = WbBoundingBox.Union(total, b);
- lb.TotalEnvCellBounds = total;
-
- // Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
- PopulatePartGroups(lb);
-
- lb.InstancesReady = true;
- lb.MeshDataReady = true;
- lb.GpuReady = true;
- }
- NeedsPrepare = true;
+ replacement.InstancesReady = true;
+ replacement.MeshDataReady = true;
+ replacement.GpuReady = true;
+ return replacement;
}
///
@@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
NeedsPrepare = true;
}
- // ---------------------------------------------------------------------------
- // PopulatePartGroups
- // Verbatim port of WB EnvCellRenderManager.cs:572-580.
- // ---------------------------------------------------------------------------
-
- ///
- /// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
- /// Must be called under lb.Lock.
- /// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
- ///
- private void PopulatePartGroups(EnvCellLandblock lb)
- {
- // WB EnvCellRenderManager.cs:573-574:
- lb.StaticPartGroups.Clear();
- lb.BuildingPartGroups.Clear();
-
- // WB EnvCellRenderManager.cs:575-579:
- foreach (var instance in lb.Instances)
- {
- var targetGroup = instance.IsBuilding ? lb.BuildingPartGroups : lb.StaticPartGroups;
- // WB: var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId.DataId;
- // We store the parent cellId in CurrentPreviewCellId (for stabs) or InstanceId (for cell geometry).
- var cellId = instance.CurrentPreviewCellId != 0 ? instance.CurrentPreviewCellId : instance.InstanceId;
- PopulateRecursive(targetGroup, instance.ObjectId, instance.IsSetup, instance.Transform, cellId);
- }
- }
-
- ///
- /// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
- /// adding one per leaf GfxObj to the target group.
- /// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
- ///
- private void PopulateRecursive(
- Dictionary> group,
- ulong objectId,
- bool isSetup,
- Matrix4x4 transform,
- uint cellId)
- {
- if (isSetup)
- {
- // For a Setup, TryGetRenderData returns a record whose SetupParts lists
- // (partId, partTransform) pairs. We recursively handle each part.
- var rd = _meshManager.TryGetRenderData(objectId);
- if (rd is null || !rd.IsSetup) return;
-
- foreach (var (partId, partTransform) in rd.SetupParts)
- {
- // Compose: world = partTransform (relative to setup) * setup world transform.
- //
- // FIX 2026-05-28: detect nested Setups via the high-byte
- // 0x02 convention (Setup IDs start with 0x02; plain GfxObj
- // IDs start with 0x01). Mirrors WB
- // ObjectRenderManagerBase.cs:813. Original port hardcoded
- // isSetup:false which silently dropped any nested Setup —
- // its TryGetRenderData returns IsSetup=true, and Render's
- // `if (!renderData.IsSetup)` guard then skips the draw.
- var combined = partTransform * transform;
- bool partIsSetup = (partId >> 24) == 0x02UL;
- PopulateRecursive(group, partId, partIsSetup, combined, cellId);
- }
- }
- else
- {
- // Plain GfxObj — just add one InstanceData.
- if (!group.TryGetValue(objectId, out var list))
- {
- list = new List();
- group[objectId] = list;
- }
- list.Add(new InstanceData
- {
- Transform = transform,
- CellId = cellId,
- Flags = 0,
- });
- }
- }
-
// ---------------------------------------------------------------------------
// PrepareRenderBatches
// Verbatim port of WB EnvCellRenderManager.cs:247-373.
@@ -1629,59 +1459,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// Helpers: bounds computation
// ---------------------------------------------------------------------------
- ///
- /// Computes a local-space AABB from a CellStruct's vertex array.
- ///
- private static WbBoundingBox ComputeLocalBoundsFromCellStruct(DatReaderWriter.Types.CellStruct cellStruct)
- {
- if (cellStruct.VertexArray is null || cellStruct.VertexArray.Vertices is null || cellStruct.VertexArray.Vertices.Count == 0)
- return new WbBoundingBox(Vector3.Zero, Vector3.Zero);
-
- var min = new Vector3(float.MaxValue);
- var max = new Vector3(float.MinValue);
- foreach (var v in cellStruct.VertexArray.Vertices.Values)
- {
- var p = v.Origin;
- min = Vector3.Min(min, p);
- max = Vector3.Max(max, p);
- }
- return new WbBoundingBox(min, max);
- }
-
- ///
- /// Transforms a local AABB to a world AABB by transforming all 8 corners.
- ///
- private static WbBoundingBox TransformBoundingBox(WbBoundingBox local, Matrix4x4 transform)
- {
- if (local.Min == local.Max && local.Min == Vector3.Zero)
- return local;
-
- var min = local.Min;
- var max = local.Max;
-
- Span corners = stackalloc Vector3[8]
- {
- new Vector3(min.X, min.Y, min.Z),
- new Vector3(max.X, min.Y, min.Z),
- new Vector3(min.X, max.Y, min.Z),
- new Vector3(max.X, max.Y, min.Z),
- new Vector3(min.X, min.Y, max.Z),
- new Vector3(max.X, min.Y, max.Z),
- new Vector3(min.X, max.Y, max.Z),
- new Vector3(max.X, max.Y, max.Z),
- };
-
- var wMin = new Vector3(float.MaxValue);
- var wMax = new Vector3(float.MinValue);
- foreach (var c in corners)
- {
- var w = Vector3.Transform(c, transform);
- wMin = Vector3.Min(wMin, w);
- wMax = Vector3.Max(wMax, w);
- }
- return new WbBoundingBox(wMin, wMax);
- }
-
// ---------------------------------------------------------------------------
// IDisposable
// ---------------------------------------------------------------------------
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs
index 43749632..55f0b34f 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs
@@ -90,18 +90,6 @@ public class EnvCellLandblock
///
public HashSet SeenOutsideCells { get; set; } = new();
- public List? PendingInstances { get; set; }
-
- ///
- /// Grouped bounding boxes for each EnvCell in this landblock (pending upload).
- ///
- public Dictionary? PendingEnvCellBounds { get; set; }
-
- ///
- /// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload).
- ///
- public HashSet? PendingSeenOutsideCells { get; set; }
-
///
/// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering.
/// Key: GfxObjId, Value: List of transforms
@@ -124,11 +112,6 @@ public class EnvCellLandblock
///
public WbBoundingBox TotalEnvCellBounds { get; set; }
- ///
- /// Total bounding box covering all EnvCells in this landblock (pending upload).
- ///
- public WbBoundingBox PendingTotalEnvCellBounds { get; set; }
-
///
/// Whether instances (positions/bounding boxes) have been generated.
///
diff --git a/src/AcDream.App/Streaming/LandblockBuild.cs b/src/AcDream.App/Streaming/LandblockBuild.cs
new file mode 100644
index 00000000..0f4485de
--- /dev/null
+++ b/src/AcDream.App/Streaming/LandblockBuild.cs
@@ -0,0 +1,16 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Complete CPU-side output of one streaming job. The worker owns this object
+/// until it posts the corresponding completion; the render thread then applies
+/// the landblock and its cell transaction together.
+///
+public sealed record LandblockBuild(
+ LoadedLandblock Landblock,
+ EnvCellLandblockBuild? EnvCells = null)
+{
+ public uint LandblockId => Landblock.LandblockId;
+}
diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs
index 050c1265..ac83cec5 100644
--- a/src/AcDream.App/Streaming/LandblockStreamJob.cs
+++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs
@@ -42,9 +42,21 @@ public abstract record LandblockStreamResult(uint LandblockId)
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
- LoadedLandblock Landblock,
+ LandblockBuild Build,
LandblockMeshData MeshData
- ) : LandblockStreamResult(LandblockId);
+ ) : LandblockStreamResult(LandblockId)
+ {
+ public Loaded(
+ uint landblockId,
+ LandblockStreamTier tier,
+ LoadedLandblock landblock,
+ LandblockMeshData meshData)
+ : this(landblockId, tier, new LandblockBuild(landblock), meshData)
+ {
+ }
+
+ public LoadedLandblock Landblock => Build.Landblock;
+ }
///
/// A previously-Far-resident landblock was promoted to Near. The result
@@ -56,10 +68,19 @@ public abstract record LandblockStreamResult(uint LandblockId)
///
public sealed record Promoted(
uint LandblockId,
- LoadedLandblock Landblock,
+ LandblockBuild Build,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId)
{
+ public Promoted(
+ uint landblockId,
+ LoadedLandblock landblock,
+ LandblockMeshData meshData)
+ : this(landblockId, new LandblockBuild(landblock), meshData)
+ {
+ }
+
+ public LoadedLandblock Landblock => Build.Landblock;
public IReadOnlyList Entities => Landblock.Entities;
}
diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs
index f5602b12..b66ce857 100644
--- a/src/AcDream.App/Streaming/LandblockStreamer.cs
+++ b/src/AcDream.App/Streaming/LandblockStreamer.cs
@@ -52,7 +52,7 @@ public sealed class LandblockStreamer : IDisposable
///
public const int DefaultDrainBatchSize = 4;
- private readonly Func _loadLandblock;
+ private readonly Func _loadLandblock;
private readonly Func _buildMeshOrNull;
private readonly Channel _inbox;
private readonly Channel _outbox;
@@ -68,7 +68,7 @@ public sealed class LandblockStreamer : IDisposable
/// LandBlockInfo + SceneryGenerator work.
///
public LandblockStreamer(
- Func loadLandblock,
+ Func loadLandblock,
Func? buildMeshOrNull = null)
{
_loadLandblock = loadLandblock;
@@ -81,6 +81,22 @@ public sealed class LandblockStreamer : IDisposable
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
+ ///
+ /// Compatibility constructor for loaders that have no App-owned cell payload.
+ /// Production uses the overload so render state
+ /// remains attached to the exact streaming completion that produced it.
+ ///
+ public LandblockStreamer(
+ Func loadLandblock,
+ Func? buildMeshOrNull = null)
+ : this(
+ (id, kind) => loadLandblock(id, kind) is { } landblock
+ ? new LandblockBuild(landblock)
+ : null,
+ buildMeshOrNull)
+ {
+ }
+
///
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
@@ -90,7 +106,11 @@ public sealed class LandblockStreamer : IDisposable
public LandblockStreamer(
Func loadLandblock,
Func? buildMeshOrNull = null)
- : this((id, _) => loadLandblock(id), buildMeshOrNull)
+ : this(
+ (id, _) => loadLandblock(id) is { } landblock
+ ? new LandblockBuild(landblock)
+ : null,
+ buildMeshOrNull)
{
}
@@ -311,13 +331,14 @@ public sealed class LandblockStreamer : IDisposable
// factory returns far-tier with entities anyway.
try
{
- var lb = _loadLandblock(load.LandblockId, load.Kind);
- if (lb is null)
+ var build = _loadLandblock(load.LandblockId, load.Kind);
+ if (build is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
break;
}
+ var lb = build.Landblock;
if (load.Kind == LandblockStreamJobKind.PromoteToNear)
{
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
@@ -328,7 +349,7 @@ public sealed class LandblockStreamer : IDisposable
break;
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
- load.LandblockId, lb, promotedMesh));
+ load.LandblockId, build, promotedMesh));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
@@ -351,9 +372,10 @@ public sealed class LandblockStreamer : IDisposable
lb.LandblockId,
lb.Heightmap,
System.Array.Empty());
+ build = new LandblockBuild(lb);
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
- load.LandblockId, tier, lb, mesh));
+ load.LandblockId, tier, build, mesh));
}
catch (Exception ex)
{
diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs
index 2e1f8682..99da350e 100644
--- a/src/AcDream.App/Streaming/StreamingController.cs
+++ b/src/AcDream.App/Streaming/StreamingController.cs
@@ -20,7 +20,7 @@ public sealed class StreamingController
private readonly Action _enqueueLoad;
private readonly Action _enqueueUnload;
private readonly Func> _drainCompletions;
- private readonly Action _applyTerrain;
+ private readonly Action _applyTerrain;
private readonly Action? _removeTerrain;
private readonly Action? _clearPendingLoads;
@@ -140,7 +140,7 @@ public sealed class StreamingController
Action enqueueLoad,
Action enqueueUnload,
Func> drainCompletions,
- Action applyTerrain,
+ Action applyTerrain,
GpuWorldState state,
int nearRadius,
int farRadius,
@@ -233,6 +233,20 @@ public sealed class StreamingController
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as .
///
+ public void InitializeKnownLoginCenter(int cx, int cy, bool isSealedDungeon)
+ {
+ // StreamingReadinessGate keeps the worker stopped until the player's
+ // real spawn supplies this center. There is therefore no guessed-center
+ // window to force-reload here. A sealed dungeon is the one special case:
+ // pin radius zero before the first Tick so no ocean-grid neighbours are
+ // ever enqueued.
+ if (isSealedDungeon)
+ PreCollapseToDungeon(cx, cy);
+ }
+
+ ///
+ /// Pins streaming to a sealed dungeon before the first normal Tick.
+ ///
public void PreCollapseToDungeon(int cx, int cy)
{
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
@@ -438,7 +452,7 @@ public sealed class StreamingController
switch (result)
{
case LandblockStreamResult.Loaded loaded:
- _applyTerrain(loaded.Landblock, loaded.MeshData);
+ _applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
// #138: after the landblock is in _loaded (so AppendLiveEntity
// hot-paths), restore any retained server objects ACE won't
@@ -446,7 +460,7 @@ public sealed class StreamingController
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break;
case LandblockStreamResult.Promoted promoted:
- _applyTerrain(promoted.Landblock, promoted.MeshData);
+ _applyTerrain(promoted.Build, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
_onLandblockLoaded?.Invoke(promoted.LandblockId);
break;
diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs
new file mode 100644
index 00000000..bfa789ae
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityCommitTests.cs
@@ -0,0 +1,39 @@
+using AcDream.App.Rendering;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class CellVisibilityCommitTests
+{
+ [Fact]
+ public void CommitLandblock_ReplacesOnlyThatLandblocksCompleteCellSet()
+ {
+ var visibility = new CellVisibility();
+ visibility.CommitLandblock(0x8C04FFFFu, new[]
+ {
+ Cell(0x8C040100u),
+ Cell(0x8C040101u),
+ });
+ visibility.CommitLandblock(0x0007FFFFu, new[] { Cell(0x00070100u) });
+
+ visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x8C040200u) });
+
+ Assert.False(visibility.TryGetCell(0x8C040100u, out _));
+ Assert.False(visibility.TryGetCell(0x8C040101u, out _));
+ Assert.True(visibility.TryGetCell(0x8C040200u, out _));
+ Assert.True(visibility.TryGetCell(0x00070100u, out _));
+ Assert.Single(visibility.GetCellsForLandblock(0x8C04u));
+ }
+
+ [Fact]
+ public void CommitLandblock_RejectsForeignCellBeforeChangingCurrentState()
+ {
+ var visibility = new CellVisibility();
+ visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x8C040100u) });
+
+ Assert.Throws(() =>
+ visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x00070100u) }));
+ Assert.True(visibility.TryGetCell(0x8C040100u, out _));
+ }
+
+ private static LoadedCell Cell(uint id) => new() { CellId = id };
+}
diff --git a/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs b/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs
index 881a8d7d..30300c8b 100644
--- a/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CellVisibilityPortalPolygonsTests.cs
@@ -1,4 +1,4 @@
-// CellVisibilityPortalPolygonsTests.cs — verifies BuildLoadedCell preserves
+// CellVisibilityPortalPolygonsTests.cs — verifies the cell-build transaction preserves
// portal polygon vertices in LoadedCell.PortalPolygons.
//
// Phase A8 — Indoor-cell visibility culling. Issue #78.
diff --git a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
index d5a0c004..3d5a8213 100644
--- a/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/CornerFloodReplayTests.cs
@@ -18,7 +18,7 @@ namespace AcDream.App.Tests.Rendering;
/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees
/// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those
/// eye positions. This harness drives the REAL
-/// over the REAL Holtburg building cells (dat-loaded, mirroring GameWindow.BuildLoadedCell)
+/// over the REAL Holtburg building cells (dat-loaded, mirroring EnvCellLandblockBuildBuilder)
/// along the captured corner eye path, and prints which cells the flood keeps/drops per
/// step. The player's room (0172) dropping while the player is visible on screen is the
/// background defect; the step where it happens pins the mechanism.
@@ -50,7 +50,7 @@ public class CornerFloodReplayTests
}
///
- /// Dat → LoadedCell, mirroring GameWindow.BuildLoadedCell (GameWindow.cs:5636-5776)
+ /// Dat → LoadedCell, mirroring EnvCellLandblockBuildBuilder.
/// field-for-field: portals (with OtherPortalId back-link), clip planes from the
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
/// cell-local space, local AABB, world transform from EnvCell.Position.
@@ -106,7 +106,7 @@ public class CornerFloodReplayTests
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
// InsideSide from the dat PortalSide bit — mirrors the production fix
- // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics
+ // (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane
diff --git a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs
index 205a3034..14b0e6c3 100644
--- a/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Issue113MeetingHallFloodTests.cs
@@ -48,7 +48,7 @@ public class Issue113MeetingHallFloodTests
return Directory.Exists(def) ? def : null;
}
- // Mirrors CornerFloodReplayTests.LoadCell (GameWindow.BuildLoadedCell shape).
+ // Mirrors CornerFloodReplayTests.LoadCell (EnvCellLandblockBuildBuilder shape).
private static LoadedCell LoadCell(DatCollection dats, uint cellId)
{
var envCell = dats.Get(cellId)
@@ -95,7 +95,7 @@ public class Issue113MeetingHallFloodTests
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0);
// InsideSide from the dat PortalSide bit — mirrors the production fix
- // (GameWindow.BuildLoadedCell): retail InitCell (0x005a4b70) + physics
+ // (EnvCellLandblockBuildBuilder): retail InitCell (0x005a4b70) + physics
// CellTransit use the dat bit; the old AABB-centroid guess mis-sided thin
// connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs
new file mode 100644
index 00000000..b53a3db9
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs
@@ -0,0 +1,104 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Wb;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+public class EnvCellLandblockBuildTests
+{
+ private const uint LandblockId = 0x8C04FFFFu;
+
+ [Fact]
+ public void Constructor_SnapshotsInputCollections()
+ {
+ var cells = new List { new() { CellId = 0x8C040100u } };
+ var shells = new List { Shell(0x8C040100u, 1) };
+
+ var build = new EnvCellLandblockBuild(LandblockId, cells, shells);
+ cells.Clear();
+ shells.Clear();
+
+ Assert.Single(build.VisibilityCells);
+ Assert.Single(build.Shells);
+ }
+
+ [Fact]
+ public void Constructor_RejectsCellFromAnotherLandblock()
+ {
+ var foreign = new LoadedCell { CellId = 0x00070100u };
+
+ Assert.Throws(() =>
+ new EnvCellLandblockBuild(LandblockId, new[] { foreign }, Array.Empty()));
+ }
+
+ [Fact]
+ public void CreateCommittedSnapshot_ContainsEveryShellInOneCompleteReplacement()
+ {
+ var shells = Enumerable.Range(0, 1123)
+ .Select(index => Shell(0x8C040100u + (uint)index, (ulong)(index % 17 + 1)))
+ .ToArray();
+ var build = new EnvCellLandblockBuild(
+ LandblockId,
+ Array.Empty(),
+ shells);
+
+ var snapshot = EnvCellRenderer.CreateCommittedSnapshot(build);
+
+ Assert.Equal(1123, snapshot.Instances.Count);
+ Assert.Equal(1123, snapshot.EnvCellBounds.Count);
+ Assert.Equal(1123, snapshot.BuildingPartGroups.Values.Sum(group => group.Count));
+ Assert.True(snapshot.InstancesReady);
+ Assert.True(snapshot.MeshDataReady);
+ Assert.True(snapshot.GpuReady);
+ }
+
+ [Fact]
+ public void IndependentBuilds_NeverSharePendingStorage()
+ {
+ var complete = new EnvCellLandblockBuild(
+ LandblockId,
+ Array.Empty(),
+ Enumerable.Range(0, 1123)
+ .Select(index => Shell(0x8C040100u + (uint)index, 1)));
+ var second = new EnvCellLandblockBuild(
+ LandblockId,
+ Array.Empty(),
+ Enumerable.Range(0, 11)
+ .Select(index => Shell(0x8C040500u + (uint)index, 2)));
+
+ var completeSnapshot = EnvCellRenderer.CreateCommittedSnapshot(complete);
+ var secondSnapshot = EnvCellRenderer.CreateCommittedSnapshot(second);
+
+ Assert.Equal(1123, completeSnapshot.Instances.Count);
+ Assert.Equal(11, secondSnapshot.Instances.Count);
+ Assert.Equal(1123, completeSnapshot.Instances.Count);
+ }
+
+ [Fact]
+ public void Builder_CanPublishItsTransactionOnlyOnce()
+ {
+ var builder = new EnvCellLandblockBuildBuilder(LandblockId);
+
+ _ = builder.Build();
+
+ Assert.Throws(() => builder.Build());
+ }
+
+ private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId)
+ {
+ var min = new Vector3(cellId & 0xFF, 0, 0);
+ var bounds = new WbBoundingBox(min, min + Vector3.One);
+ return new EnvCellShellPlacement(
+ cellId,
+ geometryId,
+ EnvironmentId: 1,
+ CellStructure: 0,
+ Surfaces: ImmutableArray.Create(1),
+ WorldPosition: min,
+ Rotation: Quaternion.Identity,
+ Transform: Matrix4x4.CreateTranslation(min),
+ LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One),
+ WorldBounds: bounds);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
index c29f299e..a55b3b5d 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
@@ -67,12 +67,4 @@ public class EnvCellSceneryInstanceTests
Assert.Equal(2, lb.BuildingPartGroups[0x01000001UL].Count);
}
- [Fact]
- public void Landblock_PendingInstancesNullByDefault()
- {
- var lb = new EnvCellLandblock();
- Assert.Null(lb.PendingInstances);
- Assert.Null(lb.PendingEnvCellBounds);
- Assert.Null(lb.PendingSeenOutsideCells);
- }
}
diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
index 3f8a4afb..da29611b 100644
--- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
@@ -1,5 +1,6 @@
using System.Threading.Tasks;
using AcDream.App.Streaming;
+using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
@@ -109,6 +110,35 @@ public class LandblockStreamerTests
Assert.Equal(1, meshBuildCalls);
}
+ [Fact]
+ public async Task Load_CarriesTheExactCompletedCellTransactionToTheConsumer()
+ {
+ var landblock = new LoadedLandblock(
+ 0x8C04FFFFu,
+ new LandBlock(),
+ System.Array.Empty());
+ var cellBuild = new EnvCellLandblockBuild(
+ landblock.LandblockId,
+ System.Array.Empty(),
+ System.Array.Empty());
+ var build = new LandblockBuild(landblock, cellBuild);
+ var mesh = new AcDream.Core.Terrain.LandblockMeshData(
+ System.Array.Empty(),
+ System.Array.Empty());
+
+ using var streamer = new LandblockStreamer(
+ loadLandblock: (_, _) => build,
+ buildMeshOrNull: (_, _) => mesh);
+ streamer.Start();
+ streamer.EnqueueLoad(landblock.LandblockId, LandblockStreamJobKind.LoadNear);
+
+ var loaded = Assert.IsType(
+ await DrainFirstAsync(streamer));
+
+ Assert.Same(build, loaded.Build);
+ Assert.Same(cellBuild, loaded.Build.EnvCells);
+ }
+
[Fact]
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
{
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
index 522a4d07..bfc76bcf 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
@@ -163,6 +163,33 @@ public class StreamingControllerDungeonGateTests
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
+ [Fact]
+ public void InitializeKnownLoginCenter_DungeonPerformsOneCollapseWithoutReloadChurn()
+ {
+ var h = Make();
+
+ h.Ctrl.InitializeKnownLoginCenter(0, 7, isSealedDungeon: true);
+ h.Ctrl.InitializeKnownLoginCenter(0, 7, isSealedDungeon: true);
+
+ Assert.Single(h.Loads);
+ Assert.Equal(Encode(0, 7), h.Loads[0].Id);
+ Assert.Equal(1, h.ClearCalls());
+ }
+
+ [Fact]
+ public void InitializeKnownLoginCenter_OutdoorWaitsForFirstCorrectlyCenteredTick()
+ {
+ var h = Make();
+
+ h.Ctrl.InitializeKnownLoginCenter(140, 4, isSealedDungeon: false);
+
+ Assert.Empty(h.Loads);
+ Assert.Equal(0, h.ClearCalls());
+
+ h.Ctrl.Tick(140, 4, insideDungeon: false);
+ Assert.Contains(h.Loads, load => load.Id == Encode(140, 4));
+ }
+
[Fact]
public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon()
{
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
index 3364d774..20f45cf2 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
@@ -73,7 +73,7 @@ public class StreamingControllerTests
var applied = new List();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
- (lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2);
+ (build, _) => applied.Add(build.Landblock), state, nearRadius: 2, farRadius: 2);
// Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs
index ad32ae08..0acab3bd 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs
@@ -131,7 +131,7 @@ public class StreamingControllerTwoTierTests
applyTerrain: (appliedLb, appliedMesh) =>
{
applyPromotedCount++;
- Assert.Same(promotedLb, appliedLb);
+ Assert.Same(promotedLb, appliedLb.Landblock);
Assert.Same(promotedMesh, appliedMesh);
},
state: state,