fix(streaming): commit EnvCell landblocks atomically (#214)

Carry one complete cell payload with each streaming completion and publish visibility, physics, and render state on the render thread. Remove the obsolete post-readiness login reload that triggered a duplicate dungeon build.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 18:45:24 +02:00
parent 85980fe13f
commit b0c175afc0
26 changed files with 973 additions and 617 deletions

View file

@ -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 ## #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 **Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate

View file

@ -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 (`PortalVisibilityBuilder` + `ClipFrame`), not a rewrite. Do NOT add a fourth special-case
gate to mask a seam — that anti-pattern produced the patchwork. 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 ## Roadmap Model
The old R1-R8 architecture sequence was a useful early refactor sketch, but it The old R1-R8 architecture sequence was a useful early refactor sketch, but it

View file

@ -183,7 +183,7 @@ src/AcDream.App/
│ ├── TextureCache.cs # (already exists) │ ├── TextureCache.cs # (already exists)
│ ├── ParticleRenderer.cs # (already exists) │ ├── ParticleRenderer.cs # (already exists)
│ ├── Sky/ # (already exists) │ ├── Sky/ # (already exists)
│ ├── Wb/ # (already exists — WB seam) │ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction
│ └── Vfx/ # (already exists) │ └── Vfx/ # (already exists)
├── Net/ ├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect │ └── 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 │ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
├── Interaction/ ├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch │ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
├── Streaming/ # (already exists) ├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
├── Input/ # (already exists) ├── Input/ # (already exists)
├── Audio/ # (already exists) ├── Audio/ # (already exists)
└── Plugins/ # (already exists) └── Plugins/ # (already exists)

View file

@ -26,6 +26,15 @@ our tree (see CLAUDE.md for the full breakdown):
`ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache, `ObjectMeshManager`, `WbMeshAdapter`, `WbDrawDispatcher`, texture cache,
shader infra, EnvCell/portal/scenery/terrain-blending pipeline classes. 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` `DatCollectionAdapter` bridges our `IDatCollection` to the `IDatReaderWriter`
interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26 interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26
internal `_dats.*` call sites — above the 20-site inline-swap threshold). internal `_dats.*` call sites — above the 20-site inline-swap threshold).

View file

@ -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.

View file

@ -263,6 +263,32 @@ public sealed class CellVisibility
_cellLookup[cell.CellId] = cell; _cellLookup[cell.CellId] = cell;
} }
/// <summary>
/// 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.
/// </summary>
/// <param name="landblockId">Full landblock id, e.g. 0xA9B4FFFF.</param>
public void CommitLandblock(uint landblockId, IReadOnlyList<LoadedCell> 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<LoadedCell>(cells);
_cellsByLandblock[prefix] = committed;
foreach (var cell in committed)
_cellLookup[cell.CellId] = cell;
}
/// <summary> /// <summary>
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a /// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
/// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when /// landblock prefix. Used by <c>GameWindow.ApplyLoadedTerrainLocked</c> when

View file

@ -191,19 +191,6 @@ public sealed class GameWindow : IDisposable
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache; private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _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<AcDream.Core.Meshing.GfxObjSubMesh>>
_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<LoadedCell> _pendingCells = new();
// Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock // Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain // id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame // 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- // closes (stale-centered geometry built during the InWorld-but-not-yet-
// located window, applied late once its build finished). // located window, applied late once its build finished).
private bool _liveCenterKnown; 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 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 // 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}"); $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX; _liveCenterX = lbX;
_liveCenterY = lbY; _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, // #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 // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes). // (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid if (spawn.Guid == _playerServerGuid
&& _streamingController is not null && _streamingController is not null)
&& IsSealedDungeonCell(p.LandblockId))
{ {
_streamingController.PreCollapseToDungeon(lbX, lbY); _streamingController.InitializeKnownLoginCenter(
lbX,
lbY,
isSealedDungeon: IsSealedDungeonCell(p.LandblockId));
} }
var origin = new System.Numerics.Vector3( var origin = new System.Numerics.Vector3(
@ -6453,7 +6424,7 @@ public sealed class GameWindow : IDisposable
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an /// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
/// early-out at the source. /// early-out at the source.
/// </summary> /// </summary>
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming( private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
uint landblockId, uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind) 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, uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind) AcDream.App.Streaming.LandblockStreamJobKind kind)
{ {
@ -6506,11 +6477,12 @@ public sealed class GameWindow : IDisposable
{ {
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId); var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
if (heightmapOnly is null) return null; if (heightmapOnly is null) return null;
return new AcDream.Core.World.LoadedLandblock( return new AcDream.App.Streaming.LandblockBuild(
landblockId, new AcDream.Core.World.LoadedLandblock(
heightmapOnly, landblockId,
System.Array.Empty<AcDream.Core.World.WorldEntity>(), heightmapOnly,
AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities System.Array.Empty<AcDream.Core.World.WorldEntity>(),
AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities
} }
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); 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. // Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated); var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); 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 // datLock fix: pre-read (under the worker's _datLock) every dat the apply
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER // consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
// `merged` so entity GfxObj/Setup ids are known. // `merged` so entity GfxObj/Setup ids are known.
var physicsDats = BuildPhysicsDatBundle(landblockId, merged); var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
return new AcDream.Core.World.LoadedLandblock( var completedEnvCells = envCellBuild.Build();
baseLoaded.LandblockId, if (_wbMeshAdapter?.MeshManager is { } meshManager)
baseLoaded.Heightmap, {
merged, AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
physicsDats); completedEnvCells,
meshManager);
}
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
merged,
physicsDats),
completedEnvCells);
} }
/// <summary> /// <summary>
@ -6898,13 +6880,18 @@ public sealed class GameWindow : IDisposable
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and /// 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. /// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
/// ///
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary) /// Portal cells and drawable shell placements are accumulated in the
/// so ApplyLoadedTerrain can drain + upload them on the render thread. /// transaction-local <paramref name="envCellBuild"/>. 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. /// Ported from pre-streaming preload lines 407-565.
/// </summary> /// </summary>
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming( private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
uint landblockId, int lbX, int lbY) uint landblockId,
int lbX,
int lbY,
AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder envCellBuild)
{ {
var result = new List<AcDream.Core.World.WorldEntity>(); var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null) return result; if (_dats is null) return result;
@ -6976,12 +6963,12 @@ public sealed class GameWindow : IDisposable
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{ {
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as // Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for // WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
// _pendingCellMeshes (still populated for any consumer) and the physics // drawable-geometry predicate; the actual shell placement is now owned
// data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes). // by this streaming job's EnvCellLandblockBuild transaction.
// Static objects inside the cell continue to flow through the dispatcher // Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work // 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 // Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct, // render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail // 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 // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident // 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 // 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 // is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). // 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); var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0) envCellBuild.AddCell(
{ envCellId,
_pendingCellMeshes[envCellId] = cellSubMeshes; envCell,
cellStruct,
// Phase A8: register the cell with EnvCellRenderer for rendering. physicsCellOrigin,
// staticObjects is empty — cell stabs continue as separate WorldEntity physicsCellTransform,
// records via the dispatcher (see lines below for the unchanged stab path). cellOrigin,
_envCellRenderer?.RegisterCell( cellTransform,
landblockId: landblockId, hasDrawableGeometry: cellSubMeshes.Count > 0);
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)>());
}
} }
} }
@ -7207,9 +7170,10 @@ public sealed class GameWindow : IDisposable
/// this callback no longer pays that CPU cost. /// this callback no longer pays that CPU cost.
/// Must only be called from the render thread. /// Must only be called from the render thread.
/// </summary> /// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb, private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData) AcDream.Core.Terrain.LandblockMeshData meshData)
{ {
var lb = build.Landblock;
if (_terrain is null || _dats is null) return; if (_terrain is null || _dats is null) return;
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId); 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; long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (_frameDiag) if (_frameDiag)
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
ApplyLoadedTerrainLocked(lb, meshData); ApplyLoadedTerrainLocked(build, meshData);
if (_frameDiag) if (_frameDiag)
{ {
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
@ -7235,164 +7199,11 @@ public sealed class GameWindow : IDisposable
} }
} }
/// <summary> private void ApplyLoadedTerrainLocked(
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it AcDream.App.Streaming.LandblockBuild build,
/// for render-thread registration. Called from the worker thread during
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
/// </summary>
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<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<System.Numerics.Vector3[]>();
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<System.Numerics.Vector3>();
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<System.Numerics.Vector3>();
}
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<uint>();
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,
AcDream.Core.Terrain.LandblockMeshData meshData) AcDream.Core.Terrain.LandblockMeshData meshData)
{ {
var lb = build.Landblock;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker). // _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below. // _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return; 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. // method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
// Step 4: drain pending LoadedCells from the worker thread. // Commit the exact visibility-cell snapshot produced by THIS streaming
// Also collect into a local dict for the BuildingLoader stamping pass below. // completion. The former global ConcurrentBag let one landblock drain
var drainedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>(); // another landblock's cells and exposed partial builds to the flood.
while (_pendingCells.TryTake(out var cell)) var committedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
if (build.EnvCells is { } envCellBuild)
{ {
_cellVisibility.AddCell(cell); if (envCellBuild.LandblockId != lb.LandblockId)
drainedCells[cell.CellId] = cell; 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 // 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. // Transform CellStruct vertices from cell-local to world space.
var rot = envCell.Position.Orientation; var rot = envCell.Position.Orientation;
var cellOriginWorld = envCell.Position.Origin + origin; 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<ushort, System.Numerics.Vector3>( var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>(
cellStruct.VertexArray.Vertices.Count); cellStruct.VertexArray.Vertices.Count);
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices) 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). // surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
if (lbInfo is not null) if (lbInfo is not null)
{ {
// Merge: previously-loaded cells + freshly-drained cells. // The streaming result now carries the complete landblock cell
// drainedCells wins on key collision (it has the same instance // set; no cross-frame merge or global-bag recovery is required.
// anyway — both dicts reference the same LoadedCell objects). var lbStampCells = committedCells;
var lbStampCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>(drainedCells);
uint lbPrefix = (lb.LandblockId >> 16) & 0xFFFFu;
foreach (var c in _cellVisibility.GetCellsForLandblock(lbPrefix))
{
if (!lbStampCells.ContainsKey(c.CellId))
lbStampCells[c.CellId] = c;
}
// FIX 2026-05-28: normalize storage key to the cell-prefix // FIX 2026-05-28: normalize storage key to the cell-prefix
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId // convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the // field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
@ -7681,15 +7505,13 @@ public sealed class GameWindow : IDisposable
lbInfo, lb.LandblockId, lbStampCells); lbInfo, lb.LandblockId, lbStampCells);
} }
// Phase A8: finalize EnvCellRenderer's per-landblock instance store. if (build.EnvCells is { } renderBuild)
// Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds, _envCellRenderer?.CommitLandblock(renderBuild);
// populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
} }
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop // 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 // Cache GfxObj physics data (BSP trees) for the physics engine — this
// loop is physics-only, not renderer-side. // loop is physics-only, not renderer-side.
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP. // [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
@ -7703,10 +7525,6 @@ public sealed class GameWindow : IDisposable
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); _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. // [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; } 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); observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 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); _streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks // 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 // a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from // 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 // 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 // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat // 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 // lacks the cell (out-of-range index / missing record) so we never collapse on a

View file

@ -188,7 +188,7 @@ public static class PortalVisibilityBuilder
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so // 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 // 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 // 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 // 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. // downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++) for (int ci = 0; ci < cameraCell.Portals.Count; ci++)

View file

@ -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;
/// <summary>
/// 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 <see cref="EnvCellLandblockBuild"/>.
/// </summary>
public sealed record EnvCellShellPlacement(
uint CellId,
ulong GeometryId,
uint EnvironmentId,
ushort CellStructure,
ImmutableArray<ushort> Surfaces,
Vector3 WorldPosition,
Quaternion Rotation,
Matrix4x4 Transform,
WbBoundingBox LocalBounds,
WbBoundingBox WorldBounds);
/// <summary>
/// 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.
/// </summary>
public sealed class EnvCellLandblockBuild
{
public EnvCellLandblockBuild(
uint landblockId,
IEnumerable<LoadedCell> visibilityCells,
IEnumerable<EnvCellShellPlacement> 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<LoadedCell> VisibilityCells { get; }
public ImmutableArray<EnvCellShellPlacement> Shells { get; }
}
/// <summary>
/// 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 <see cref="Build"/> returns.
/// </summary>
public sealed class EnvCellLandblockBuildBuilder
{
private readonly uint _landblockId;
private readonly List<LoadedCell> _visibilityCells = new();
private readonly List<EnvCellShellPlacement> _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);
}
/// <summary>
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim arithmetic).
/// </summary>
public static ulong ComputeGeometryId(
uint environmentId,
ushort cellStructure,
IReadOnlyList<ushort> 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<Vector3> 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);
}
/// <summary>
/// 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.
/// </summary>
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<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
var portalPolygons = new List<Vector3[]>();
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<Vector3>();
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<Vector3>();
}
portalPolygons.Add(polygonVertices);
}
uint landblockPrefix = envCellId & 0xFFFF0000u;
var visibleCells = new List<uint>();
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),
};
}
}

View file

@ -0,0 +1,23 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// 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.
/// </summary>
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<ushort>(shell.Surfaces));
}
}
}

View file

@ -8,15 +8,14 @@
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373 // PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511 // Render(filter:) <- WB EnvCellRenderManager.cs:395-511
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant) // RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393 // AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
// //
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base // Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
// class owns the landblock-streaming loop (Update, _pendingGeneration, // class owns the landblock-streaming loop (Update, _pendingGeneration,
// _uploadQueue). acdream's StreamingController already does that work — // _uploadQueue). acdream's StreamingController already does that work —
// running a parallel loop would compete for dat I/O. Instead, we expose // running a parallel loop would compete for dat I/O. Instead, streaming builds
// RegisterCell(...) as the seam: callers populate our instance store at // a private EnvCellLandblockBuild and CommitLandblock publishes the completed
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming). // snapshot on the render thread.
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@ -36,7 +35,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private readonly ObjectMeshManager _meshManager; private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum; 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<ushort, ObjectLandblock> _landblocks — // WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout. // we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new(); private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim). /// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
/// </summary> /// </summary>
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces) public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
{ => EnvCellLandblockBuildBuilder.ComputeGeometryId(
// WB EnvCellRenderManager.cs:95-102 verbatim: environmentId,
var hash = 17L; cellStructure,
hash = hash * 31 + (int)environmentId; surfaces);
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;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RegisterCell — streaming seam // CommitLandblock — render-thread transaction boundary
// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
// time, ONCE per EnvCell that has non-null EnvironmentId.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// <summary> /// <summary>
/// Registers a single EnvCell and its static objects into the per-landblock /// Commits one complete worker-built landblock snapshot. A replacement is
/// pending list. Call <see cref="FinalizeLandblock"/> after all cells for a /// constructed off to the side, then published with one dictionary write;
/// landblock have been registered. /// render preparation can observe either the previous complete snapshot or
/// this complete snapshot, never an in-progress cell list.
/// </summary> /// </summary>
public void RegisterCell( public void CommitLandblock(EnvCellLandblockBuild build)
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)
{ {
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId). _landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces); NeedsPrepare = true;
}
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager. /// <summary>
// This populates ObjectMeshManager._renderData[cellGeomId] when complete. /// Pure half of <see cref="CommitLandblock"/>. Kept separate so transaction
// Verified Phase W Stage 4 (T4.4): ceilings are present by construction. /// replacement semantics are regression-tested without an OpenGL context.
// PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons /// </summary>
// (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build)
// surface filter — every polygon that passes the ≥3-vertex check is added to {
// the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which var replacement = new EnvCellLandblock
// 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
{ {
ObjectId = cellGeomId, GridX = (int)((build.LandblockId >> 24) & 0xFFu),
InstanceId = envCellId, // uint InstanceId maps to the cell id GridY = (int)((build.LandblockId >> 16) & 0xFFu),
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,
}; };
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock foreach (var shell in build.Shells)
{ {
GridX = (int)((id >> 24) & 0xFFu), replacement.Instances.Add(new EnvCellSceneryInstance
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<EnvCellSceneryInstance>(capacity: 32);
lb.PendingInstances.Add(cellInstance);
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
lb.PendingEnvCellBounds[envCellId] = worldBounds;
// Add static-object instances inside the cell.
foreach (var stab in staticObjects)
{ {
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes). ObjectId = shell.GeometryId,
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup); 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; if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup); {
if (rawBounds.HasValue) instances = new List<InstanceData>();
stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max); replacement.BuildingPartGroups[shell.GeometryId] = instances;
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);
} }
instances.Add(new InstanceData
{
Transform = shell.Transform,
CellId = shell.CellId,
Flags = 0,
});
} }
}
/// <summary> var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
/// Atomically promotes the pending instance list to the committed list, foreach (var bounds in replacement.EnvCellBounds.Values)
/// computes total EnvCell bounds, populates PartGroups, and marks the total = WbBoundingBox.Union(total, bounds);
/// landblock ready for rendering. replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0
/// </summary> ? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
public void FinalizeLandblock(uint landblockId) : total;
{
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;
}
// Compute total bounds union across all cells. replacement.InstancesReady = true;
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); replacement.MeshDataReady = true;
foreach (var b in lb.EnvCellBounds.Values) replacement.GpuReady = true;
total = WbBoundingBox.Union(total, b); return replacement;
lb.TotalEnvCellBounds = total;
// Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
PopulatePartGroups(lb);
lb.InstancesReady = true;
lb.MeshDataReady = true;
lb.GpuReady = true;
}
NeedsPrepare = true;
} }
/// <summary> /// <summary>
@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
NeedsPrepare = true; NeedsPrepare = true;
} }
// ---------------------------------------------------------------------------
// PopulatePartGroups
// Verbatim port of WB EnvCellRenderManager.cs:572-580.
// ---------------------------------------------------------------------------
/// <summary>
/// Rebuilds BuildingPartGroups and StaticPartGroups from the committed Instances list.
/// Must be called under lb.Lock.
/// Source: WB EnvCellRenderManager.cs:572-580 (verbatim).
/// </summary>
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);
}
}
/// <summary>
/// Recursively walks a Setup's parts (or handles a plain GfxObj directly),
/// adding one <see cref="InstanceData"/> per leaf GfxObj to the target group.
/// Mirrors WB ObjectRenderManagerBase.PopulateRecursive.
/// </summary>
private void PopulateRecursive(
Dictionary<ulong, List<InstanceData>> 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<InstanceData>();
group[objectId] = list;
}
list.Add(new InstanceData
{
Transform = transform,
CellId = cellId,
Flags = 0,
});
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PrepareRenderBatches // PrepareRenderBatches
// Verbatim port of WB EnvCellRenderManager.cs:247-373. // Verbatim port of WB EnvCellRenderManager.cs:247-373.
@ -1629,59 +1459,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// Helpers: bounds computation // Helpers: bounds computation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// <summary>
/// Computes a local-space AABB from a CellStruct's vertex array.
/// </summary>
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);
}
/// <summary>
/// Transforms a local AABB to a world AABB by transforming all 8 corners.
/// </summary>
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<Vector3> 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 // IDisposable
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -90,18 +90,6 @@ public class EnvCellLandblock
/// </summary> /// </summary>
public HashSet<uint> SeenOutsideCells { get; set; } = new(); public HashSet<uint> SeenOutsideCells { get; set; } = new();
public List<EnvCellSceneryInstance>? PendingInstances { get; set; }
/// <summary>
/// Grouped bounding boxes for each EnvCell in this landblock (pending upload).
/// </summary>
public Dictionary<uint, WbBoundingBox>? PendingEnvCellBounds { get; set; }
/// <summary>
/// Set of EnvCell IDs in this landblock that have the SeenOutside flag (pending upload).
/// </summary>
public HashSet<uint>? PendingSeenOutsideCells { get; set; }
/// <summary> /// <summary>
/// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering. /// Grouped transforms for each GfxObj part for static objects, for efficient instanced rendering.
/// Key: GfxObjId, Value: List of transforms /// Key: GfxObjId, Value: List of transforms
@ -124,11 +112,6 @@ public class EnvCellLandblock
/// </summary> /// </summary>
public WbBoundingBox TotalEnvCellBounds { get; set; } public WbBoundingBox TotalEnvCellBounds { get; set; }
/// <summary>
/// Total bounding box covering all EnvCells in this landblock (pending upload).
/// </summary>
public WbBoundingBox PendingTotalEnvCellBounds { get; set; }
/// <summary> /// <summary>
/// Whether instances (positions/bounding boxes) have been generated. /// Whether instances (positions/bounding boxes) have been generated.
/// </summary> /// </summary>

View file

@ -0,0 +1,16 @@
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// 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.
/// </summary>
public sealed record LandblockBuild(
LoadedLandblock Landblock,
EnvCellLandblockBuild? EnvCells = null)
{
public uint LandblockId => Landblock.LandblockId;
}

View file

@ -42,9 +42,21 @@ public abstract record LandblockStreamResult(uint LandblockId)
public sealed record Loaded( public sealed record Loaded(
uint LandblockId, uint LandblockId,
LandblockStreamTier Tier, LandblockStreamTier Tier,
LoadedLandblock Landblock, LandblockBuild Build,
LandblockMeshData MeshData 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;
}
/// <summary> /// <summary>
/// A previously-Far-resident landblock was promoted to Near. The result /// A previously-Far-resident landblock was promoted to Near. The result
@ -56,10 +68,19 @@ public abstract record LandblockStreamResult(uint LandblockId)
/// </summary> /// </summary>
public sealed record Promoted( public sealed record Promoted(
uint LandblockId, uint LandblockId,
LoadedLandblock Landblock, LandblockBuild Build,
LandblockMeshData MeshData LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId) ) : LandblockStreamResult(LandblockId)
{ {
public Promoted(
uint landblockId,
LoadedLandblock landblock,
LandblockMeshData meshData)
: this(landblockId, new LandblockBuild(landblock), meshData)
{
}
public LoadedLandblock Landblock => Build.Landblock;
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities; public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
} }

View file

@ -52,7 +52,7 @@ public sealed class LandblockStreamer : IDisposable
/// </summary> /// </summary>
public const int DefaultDrainBatchSize = 4; public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock; private readonly Func<uint, LandblockStreamJobKind, LandblockBuild?> _loadLandblock;
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull; private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
private readonly Channel<LandblockStreamJob> _inbox; private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox; private readonly Channel<LandblockStreamResult> _outbox;
@ -68,7 +68,7 @@ public sealed class LandblockStreamer : IDisposable
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work. /// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
/// </summary> /// </summary>
public LandblockStreamer( public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock, Func<uint, LandblockStreamJobKind, LandblockBuild?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null) Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
{ {
_loadLandblock = loadLandblock; _loadLandblock = loadLandblock;
@ -81,6 +81,22 @@ public sealed class LandblockStreamer : IDisposable
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
} }
/// <summary>
/// Compatibility constructor for loaders that have no App-owned cell payload.
/// Production uses the <see cref="LandblockBuild"/> overload so render state
/// remains attached to the exact streaming completion that produced it.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this(
(id, kind) => loadLandblock(id, kind) is { } landblock
? new LandblockBuild(landblock)
: null,
buildMeshOrNull)
{
}
/// <summary> /// <summary>
/// Back-compat overload — wraps a kind-agnostic factory so existing test code /// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper /// that doesn't care about the JobKind branch keeps compiling. The wrapper
@ -90,7 +106,11 @@ public sealed class LandblockStreamer : IDisposable
public LandblockStreamer( public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock, Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null) Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? 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. // factory returns far-tier with entities anyway.
try try
{ {
var lb = _loadLandblock(load.LandblockId, load.Kind); var build = _loadLandblock(load.LandblockId, load.Kind);
if (lb is null) if (build is null)
{ {
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed( _outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null")); load.LandblockId, "LandblockLoader.Load returned null"));
break; break;
} }
var lb = build.Landblock;
if (load.Kind == LandblockStreamJobKind.PromoteToNear) if (load.Kind == LandblockStreamJobKind.PromoteToNear)
{ {
var promotedMesh = _buildMeshOrNull(load.LandblockId, lb); var promotedMesh = _buildMeshOrNull(load.LandblockId, lb);
@ -328,7 +349,7 @@ public sealed class LandblockStreamer : IDisposable
break; break;
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.Promoted( _outbox.Writer.TryWrite(new LandblockStreamResult.Promoted(
load.LandblockId, lb, promotedMesh)); load.LandblockId, build, promotedMesh));
break; break;
} }
var mesh = _buildMeshOrNull(load.LandblockId, lb); var mesh = _buildMeshOrNull(load.LandblockId, lb);
@ -351,9 +372,10 @@ public sealed class LandblockStreamer : IDisposable
lb.LandblockId, lb.LandblockId,
lb.Heightmap, lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>()); System.Array.Empty<AcDream.Core.World.WorldEntity>());
build = new LandblockBuild(lb);
} }
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded( _outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh)); load.LandblockId, tier, build, mesh));
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -20,7 +20,7 @@ public sealed class StreamingController
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad; private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload; private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions; private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain; private readonly Action<LandblockBuild, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain; private readonly Action<uint>? _removeTerrain;
private readonly Action? _clearPendingLoads; private readonly Action? _clearPendingLoads;
@ -140,7 +140,7 @@ public sealed class StreamingController
Action<uint, LandblockStreamJobKind> enqueueLoad, Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload, Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions, Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock, LandblockMeshData> applyTerrain, Action<LandblockBuild, LandblockMeshData> applyTerrain,
GpuWorldState state, GpuWorldState state,
int nearRadius, int nearRadius,
int farRadius, int farRadius,
@ -233,6 +233,20 @@ public sealed class StreamingController
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only, /// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
/// same as <see cref="Tick"/>.</para> /// same as <see cref="Tick"/>.</para>
/// </summary> /// </summary>
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);
}
/// <summary>
/// Pins streaming to a sealed dungeon before the first normal Tick.
/// </summary>
public void PreCollapseToDungeon(int cx, int cy) public void PreCollapseToDungeon(int cx, int cy)
{ {
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
@ -438,7 +452,7 @@ public sealed class StreamingController
switch (result) switch (result)
{ {
case LandblockStreamResult.Loaded loaded: case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock, loaded.MeshData); _applyTerrain(loaded.Build, loaded.MeshData);
_state.AddLandblock(loaded.Landblock); _state.AddLandblock(loaded.Landblock);
// #138: after the landblock is in _loaded (so AppendLiveEntity // #138: after the landblock is in _loaded (so AppendLiveEntity
// hot-paths), restore any retained server objects ACE won't // hot-paths), restore any retained server objects ACE won't
@ -446,7 +460,7 @@ public sealed class StreamingController
_onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId);
break; break;
case LandblockStreamResult.Promoted promoted: case LandblockStreamResult.Promoted promoted:
_applyTerrain(promoted.Landblock, promoted.MeshData); _applyTerrain(promoted.Build, promoted.MeshData);
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
_onLandblockLoaded?.Invoke(promoted.LandblockId); _onLandblockLoaded?.Invoke(promoted.LandblockId);
break; break;

View file

@ -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<ArgumentException>(() =>
visibility.CommitLandblock(0x8C04FFFFu, new[] { Cell(0x00070100u) }));
Assert.True(visibility.TryGetCell(0x8C040100u, out _));
}
private static LoadedCell Cell(uint id) => new() { CellId = id };
}

View file

@ -1,4 +1,4 @@
// CellVisibilityPortalPolygonsTests.cs — verifies BuildLoadedCell preserves // CellVisibilityPortalPolygonsTests.cs — verifies the cell-build transaction preserves
// portal polygon vertices in LoadedCell.PortalPolygons. // portal polygon vertices in LoadedCell.PortalPolygons.
// //
// Phase A8 — Indoor-cell visibility culling. Issue #78. // Phase A8 — Indoor-cell visibility culling. Issue #78.

View file

@ -18,7 +18,7 @@ namespace AcDream.App.Tests.Rendering;
/// the neighbour room through the 0171↔0173↔0172 doorway chain. The user still sees /// 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 /// BACKGROUND at certain angles — so the defect is in the FLOOD/CLIP output for those
/// eye positions. This harness drives the REAL <see cref="PortalVisibilityBuilder.Build"/> /// eye positions. This harness drives the REAL <see cref="PortalVisibilityBuilder.Build"/>
/// 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 /// 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 /// 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. /// background defect; the step where it happens pins the mechanism.
@ -50,7 +50,7 @@ public class CornerFloodReplayTests
} }
/// <summary> /// <summary>
/// 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 /// field-for-field: portals (with OtherPortalId back-link), clip planes from the
/// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in /// portal polygon's first 3 verts + centroid InsideSide, full portal polygons in
/// cell-local space, local AABB, world transform from EnvCell.Position. /// 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)); var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0); float d = -Vector3.Dot(normal, p0);
// InsideSide from the dat PortalSide bit — mirrors the production fix // 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 // 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. // connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane clipPlanes.Add(new PortalClipPlane

View file

@ -48,7 +48,7 @@ public class Issue113MeetingHallFloodTests
return Directory.Exists(def) ? def : null; 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) private static LoadedCell LoadCell(DatCollection dats, uint cellId)
{ {
var envCell = dats.Get<DatEnvCell>(cellId) var envCell = dats.Get<DatEnvCell>(cellId)
@ -95,7 +95,7 @@ public class Issue113MeetingHallFloodTests
var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0)); var normal = Vector3.Normalize(Vector3.Cross(p1 - p0, p2 - p0));
float d = -Vector3.Dot(normal, p0); float d = -Vector3.Dot(normal, p0);
// InsideSide from the dat PortalSide bit — mirrors the production fix // 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 // 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. // connectors (#186). Kept identical here so this replay stays a faithful mirror.
clipPlanes.Add(new PortalClipPlane clipPlanes.Add(new PortalClipPlane

View file

@ -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<LoadedCell> { new() { CellId = 0x8C040100u } };
var shells = new List<EnvCellShellPlacement> { 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<ArgumentException>(() =>
new EnvCellLandblockBuild(LandblockId, new[] { foreign }, Array.Empty<EnvCellShellPlacement>()));
}
[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<LoadedCell>(),
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<LoadedCell>(),
Enumerable.Range(0, 1123)
.Select(index => Shell(0x8C040100u + (uint)index, 1)));
var second = new EnvCellLandblockBuild(
LandblockId,
Array.Empty<LoadedCell>(),
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<InvalidOperationException>(() => 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<ushort>(1),
WorldPosition: min,
Rotation: Quaternion.Identity,
Transform: Matrix4x4.CreateTranslation(min),
LocalBounds: new WbBoundingBox(Vector3.Zero, Vector3.One),
WorldBounds: bounds);
}
}

View file

@ -67,12 +67,4 @@ public class EnvCellSceneryInstanceTests
Assert.Equal(2, lb.BuildingPartGroups[0x01000001UL].Count); 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);
}
} }

View file

@ -1,5 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World; using AcDream.Core.World;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using Xunit; using Xunit;
@ -109,6 +110,35 @@ public class LandblockStreamerTests
Assert.Equal(1, meshBuildCalls); Assert.Equal(1, meshBuildCalls);
} }
[Fact]
public async Task Load_CarriesTheExactCompletedCellTransactionToTheConsumer()
{
var landblock = new LoadedLandblock(
0x8C04FFFFu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
var cellBuild = new EnvCellLandblockBuild(
landblock.LandblockId,
System.Array.Empty<AcDream.App.Rendering.LoadedCell>(),
System.Array.Empty<EnvCellShellPlacement>());
var build = new LandblockBuild(landblock, cellBuild);
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
using var streamer = new LandblockStreamer(
loadLandblock: (_, _) => build,
buildMeshOrNull: (_, _) => mesh);
streamer.Start();
streamer.EnqueueLoad(landblock.LandblockId, LandblockStreamJobKind.LoadNear);
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
await DrainFirstAsync(streamer));
Assert.Same(build, loaded.Build);
Assert.Same(cellBuild, loaded.Build.EnvCells);
}
[Fact] [Fact]
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock() public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
{ {

View file

@ -163,6 +163,33 @@ public class StreamingControllerDungeonGateTests
Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); 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] [Fact]
public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon()
{ {

View file

@ -73,7 +73,7 @@ public class StreamingControllerTests
var applied = new List<LoadedLandblock>(); var applied = new List<LoadedLandblock>();
var controller = new StreamingController( var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions, 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, // Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg // Entities (positional record). Adjust if the first positional arg

View file

@ -131,7 +131,7 @@ public class StreamingControllerTwoTierTests
applyTerrain: (appliedLb, appliedMesh) => applyTerrain: (appliedLb, appliedMesh) =>
{ {
applyPromotedCount++; applyPromotedCount++;
Assert.Same(promotedLb, appliedLb); Assert.Same(promotedLb, appliedLb.Landblock);
Assert.Same(promotedMesh, appliedMesh); Assert.Same(promotedMesh, appliedMesh);
}, },
state: state, state: state,