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

@ -8,15 +8,14 @@
// PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373
// Render(filter:) <- WB EnvCellRenderManager.cs:395-511
// RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant)
// PopulatePartGroups <- WB EnvCellRenderManager.cs:572-580
// AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393
//
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
// class owns the landblock-streaming loop (Update, _pendingGeneration,
// _uploadQueue). acdream's StreamingController already does that work —
// running a parallel loop would compete for dat I/O. Instead, we expose
// RegisterCell(...) as the seam: callers populate our instance store at
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
// running a parallel loop would compete for dat I/O. Instead, streaming builds
// a private EnvCellLandblockBuild and CommitLandblock publishes the completed
// snapshot on the render thread.
using System;
using System.Collections.Concurrent;
@ -36,7 +35,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private readonly ObjectMeshManager _meshManager;
private readonly WbFrustum _frustum;
// Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
// Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF).
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
@ -308,171 +307,81 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Source: WB EnvCellRenderManager.cs:94-103 (verbatim).
/// </summary>
public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List<ushort> surfaces)
{
// WB EnvCellRenderManager.cs:95-102 verbatim:
var hash = 17L;
hash = hash * 31 + (int)environmentId;
hash = hash * 31 + cellStructure;
foreach (var surface in surfaces) {
hash = hash * 31 + surface;
}
// Use bit 33 to indicate deduplicated EnvCell geometry (to avoid collision with bit 32 per-cell geometry)
return (ulong)hash | 0x2_0000_0000UL;
}
=> EnvCellLandblockBuildBuilder.ComputeGeometryId(
environmentId,
cellStructure,
surfaces);
// ---------------------------------------------------------------------------
// RegisterCell — streaming seam
// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
// time, ONCE per EnvCell that has non-null EnvironmentId.
// CommitLandblock — render-thread transaction boundary
// ---------------------------------------------------------------------------
/// <summary>
/// Registers a single EnvCell and its static objects into the per-landblock
/// pending list. Call <see cref="FinalizeLandblock"/> after all cells for a
/// landblock have been registered.
/// Commits one complete worker-built landblock snapshot. A replacement is
/// constructed off to the side, then published with one dictionary write;
/// render preparation can observe either the previous complete snapshot or
/// this complete snapshot, never an in-progress cell list.
/// </summary>
public void RegisterCell(
uint landblockId,
uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
DatReaderWriter.Types.CellStruct cellStruct,
Matrix4x4 cellTransform,
Vector3 cellWorldPosition,
Quaternion cellRotation,
IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
public void CommitLandblock(EnvCellLandblockBuild build)
{
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
_landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
NeedsPrepare = true;
}
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
// This populates ObjectMeshManager._renderData[cellGeomId] when complete.
// Verified Phase W Stage 4 (T4.4): ceilings are present by construction.
// PrepareCellStructMeshData iterates ALL polygons in CellStruct.Polygons
// (floor + 4 walls + ceiling, as authored in the EnvCell dat mesh) with NO
// surface filter — every polygon that passes the ≥3-vertex check is added to
// the batch. Retail PView::DrawCells draws cell->structure->drawing_bsp which
// is the same closed-box structure. No ceiling polygons are dropped.
_ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
envCell.CellStructure, envCell.Surfaces);
// 3. Compute local bounds from cellStruct vertex array.
var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
var worldBounds = TransformBoundingBox(localBounds, cellTransform);
// 4. Create the per-cell SceneryInstance.
var cellInstance = new EnvCellSceneryInstance
/// <summary>
/// Pure half of <see cref="CommitLandblock"/>. Kept separate so transaction
/// replacement semantics are regression-tested without an OpenGL context.
/// </summary>
internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build)
{
var replacement = new EnvCellLandblock
{
ObjectId = cellGeomId,
InstanceId = envCellId, // uint InstanceId maps to the cell id
IsBuilding = true,
IsEntryCell = false, // could be derived from entry-portal walk; default false
WorldPosition = cellWorldPosition,
LocalPosition = Vector3.Zero,
Rotation = cellRotation,
Scale = Vector3.One,
Transform = cellTransform,
LocalBoundingBox = localBounds,
BoundingBox = worldBounds,
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
};
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
foreach (var shell in build.Shells)
{
GridX = (int)((id >> 24) & 0xFFu),
GridY = (int)((id >> 16) & 0xFFu),
});
lock (lb.Lock)
{
// TEMP diagnostic #105 (strip with fix): a registration landing AFTER
// this landblock was already finalized starts a fresh pending list that
// only commits if ANOTHER finalize arrives — and that one will REPLACE
// (not merge) the committed set. One-shot per landblock per pending list.
if (lb.InstancesReady && lb.PendingInstances is null)
Console.WriteLine($"[late-register] lb=0x{landblockId:X8} cell=0x{envCellId:X8} registered AFTER finalize — starting a new pending list ({(lb.Instances?.Count ?? 0)} already committed)");
lb.PendingInstances ??= new List<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)
replacement.Instances.Add(new EnvCellSceneryInstance
{
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
ObjectId = shell.GeometryId,
InstanceId = shell.CellId,
IsBuilding = true,
IsEntryCell = false,
WorldPosition = shell.WorldPosition,
LocalPosition = Vector3.Zero,
Rotation = shell.Rotation,
Scale = Vector3.One,
Transform = shell.Transform,
LocalBoundingBox = shell.LocalBounds,
BoundingBox = shell.WorldBounds,
});
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
WbBoundingBox stabBoundsLocal;
var rawBounds = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup);
if (rawBounds.HasValue)
stabBoundsLocal = new WbBoundingBox(rawBounds.Value.Min, rawBounds.Value.Max);
else
stabBoundsLocal = new WbBoundingBox(Vector3.Zero, Vector3.Zero);
var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
lb.PendingInstances.Add(new EnvCellSceneryInstance
{
ObjectId = stab.StaticObjectId,
InstanceId = 0,
IsSetup = stab.IsSetup,
IsBuilding = false,
Transform = stab.Transform,
BoundingBox = stabBoundsWorld,
LocalBoundingBox = stabBoundsLocal,
Scale = Vector3.One,
Rotation = stab.Rot,
// CurrentPreviewCellId = 0; the per-cell CellId is written during PopulatePartGroups
CurrentPreviewCellId = envCellId, // carry the parent cellId for PartGroups dispatch
});
// Union the cell bounds with the stab bounds.
var current = lb.PendingEnvCellBounds[envCellId];
lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
{
instances = new List<InstanceData>();
replacement.BuildingPartGroups[shell.GeometryId] = instances;
}
instances.Add(new InstanceData
{
Transform = shell.Transform,
CellId = shell.CellId,
Flags = 0,
});
}
}
/// <summary>
/// Atomically promotes the pending instance list to the committed list,
/// computes total EnvCell bounds, populates PartGroups, and marks the
/// landblock ready for rendering.
/// </summary>
public void FinalizeLandblock(uint landblockId)
{
if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
lock (lb.Lock)
{
if (lb.PendingInstances is not null)
{
// TEMP diagnostic #105 (strip with fix): REPLACE semantics — if a
// previous finalize already committed instances for this landblock,
// this swap DISCARDS them in favor of the new pending set. A partial
// pending set (finalize racing a still-registering promote job)
// silently loses buildings.
if (lb.Instances is { Count: > 0 })
Console.WriteLine($"[finalize-replace] lb=0x{landblockId:X8} DISCARDING {lb.Instances.Count} committed instances, replacing with {lb.PendingInstances.Count} pending");
lb.Instances = lb.PendingInstances;
lb.PendingInstances = null;
}
if (lb.PendingEnvCellBounds is not null)
{
lb.EnvCellBounds = lb.PendingEnvCellBounds;
lb.PendingEnvCellBounds = null;
}
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
foreach (var bounds in replacement.EnvCellBounds.Values)
total = WbBoundingBox.Union(total, bounds);
replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
: total;
// Compute total bounds union across all cells.
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
foreach (var b in lb.EnvCellBounds.Values)
total = WbBoundingBox.Union(total, b);
lb.TotalEnvCellBounds = total;
// Populate PartGroups (mirrors WB EnvCellRenderManager.cs:572-580).
PopulatePartGroups(lb);
lb.InstancesReady = true;
lb.MeshDataReady = true;
lb.GpuReady = true;
}
NeedsPrepare = true;
replacement.InstancesReady = true;
replacement.MeshDataReady = true;
replacement.GpuReady = true;
return replacement;
}
/// <summary>
@ -484,85 +393,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
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
// Verbatim port of WB EnvCellRenderManager.cs:247-373.
@ -1629,59 +1459,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// 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
// ---------------------------------------------------------------------------