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

@ -191,19 +191,6 @@ public sealed class GameWindow : IDisposable
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<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
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame
@ -991,17 +978,6 @@ public sealed class GameWindow : IDisposable
// closes (stale-centered geometry built during the InWorld-but-not-yet-
// located window, applied late once its build finished).
private bool _liveCenterKnown;
// Set by the login-spawn recenter (network thread) when the spawn landblock
// differs from the startup streaming center; consumed on the render thread
// in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far
// login-spawn moves the render origin just like an outdoor teleport, so the
// streaming region — bootstrapped around the stale startup center — must be
// rebuilt fresh around the spawn. Without this, RecenterTo treats the
// old/new window overlap as already-resident (MarkResidentFromBootstrap marks
// residence before the async loads land), leaving a permanent hole of
// resident-but-never-loaded landblocks where the player spawns — the
// cold-spawn "invisible player / world not loading" bug (2026-07-03).
private volatile bool _pendingForceReloadWindow;
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
@ -3182,13 +3158,6 @@ public sealed class GameWindow : IDisposable
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
_liveCenterX = lbX;
_liveCenterY = lbY;
// The origin jumped — the streaming region is still bootstrapped
// around the stale startup center with residence marked before its
// async loads landed. Flag a full window rebuild (consumed on the
// render thread in OnUpdateFrame) so the region re-bootstraps fresh
// around the spawn, re-loading the landblocks RecenterTo would
// otherwise skip as already-resident — the cold-spawn streaming hole.
_pendingForceReloadWindow = true;
}
// #135: the instant we know the player spawned into a SEALED dungeon,
@ -3202,10 +3171,12 @@ public sealed class GameWindow : IDisposable
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
// (reentrant); the controller call is render-thread-safe (Channel writes).
if (spawn.Guid == _playerServerGuid
&& _streamingController is not null
&& IsSealedDungeonCell(p.LandblockId))
&& _streamingController is not null)
{
_streamingController.PreCollapseToDungeon(lbX, lbY);
_streamingController.InitializeKnownLoginCenter(
lbX,
lbY,
isSealedDungeon: IsSealedDungeonCell(p.LandblockId));
}
var origin = new System.Numerics.Vector3(
@ -6453,7 +6424,7 @@ public sealed class GameWindow : IDisposable
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
/// early-out at the source.
/// </summary>
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreaming(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
@ -6491,7 +6462,7 @@ public sealed class GameWindow : IDisposable
}
}
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(
private AcDream.App.Streaming.LandblockBuild? BuildLandblockForStreamingLocked(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
@ -6506,11 +6477,12 @@ public sealed class GameWindow : IDisposable
{
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
if (heightmapOnly is null) return null;
return new AcDream.Core.World.LoadedLandblock(
landblockId,
heightmapOnly,
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
landblockId,
heightmapOnly,
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
AcDream.Core.World.PhysicsDatBundle.Empty)); // far tier: no cells/buildings/entities
}
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
@ -6587,17 +6559,27 @@ public sealed class GameWindow : IDisposable
// Task 8: merge stabs + scenery + interior into one entity list.
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
var envCellBuild = new AcDream.App.Rendering.Wb.EnvCellLandblockBuildBuilder(landblockId);
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY, envCellBuild));
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
// `merged` so entity GfxObj/Setup ids are known.
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
return new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
merged,
physicsDats);
var completedEnvCells = envCellBuild.Build();
if (_wbMeshAdapter?.MeshManager is { } meshManager)
{
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
completedEnvCells,
meshManager);
}
return new AcDream.App.Streaming.LandblockBuild(
new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
merged,
physicsDats),
completedEnvCells);
}
/// <summary>
@ -6898,13 +6880,18 @@ public sealed class GameWindow : IDisposable
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
/// so ApplyLoadedTerrain can drain + upload them on the render thread.
/// Portal cells and drawable shell placements are accumulated in the
/// transaction-local <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.
/// </summary>
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>();
if (_dats is null) return result;
@ -6976,12 +6963,12 @@ public sealed class GameWindow : IDisposable
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
// _pendingCellMeshes (still populated for any consumer) and the physics
// data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes).
// WorldEntities with fake MeshRefs. CellMesh.Build remains the existing
// drawable-geometry predicate; the actual shell placement is now owned
// by this streaming job's EnvCellLandblockBuild transaction.
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
// fine; EnvCellRenderer receives only the completed shell transaction.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
@ -7007,44 +6994,20 @@ public sealed class GameWindow : IDisposable
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
// before the flood runs; the cell-build transaction reads portals, NOT
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
// is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
// un-cached connector 0x014D — so the side test culled the on-screen connector
// portal and the grey clear showed through). Retail keeps the whole landblock
// cell array resident for the sweep; a portals-only connector has an empty
// collision BSP but its portals drive the transit. CacheCellStruct reads the
// cellStruct directly, not the render sub-meshes.
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Phase A8: register the cell with EnvCellRenderer for rendering.
// staticObjects is empty — cell stabs continue as separate WorldEntity
// records via the dispatcher (see lines below for the unchanged stab path).
_envCellRenderer?.RegisterCell(
landblockId: landblockId,
envCellId: envCellId,
envCell: envCell,
cellStruct: cellStruct,
cellTransform: cellTransform,
cellWorldPosition: cellOrigin,
cellRotation: envCell.Position.Orientation,
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
}
envCellBuild.AddCell(
envCellId,
envCell,
cellStruct,
physicsCellOrigin,
physicsCellTransform,
cellOrigin,
cellTransform,
hasDrawableGeometry: cellSubMeshes.Count > 0);
}
}
@ -7207,9 +7170,10 @@ public sealed class GameWindow : IDisposable
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread.
/// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb,
private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
var lb = build.Landblock;
if (_terrain is null || _dats is null) return;
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
@ -7227,7 +7191,7 @@ public sealed class GameWindow : IDisposable
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
if (_frameDiag)
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
ApplyLoadedTerrainLocked(lb, meshData);
ApplyLoadedTerrainLocked(build, meshData);
if (_frameDiag)
{
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
@ -7235,164 +7199,11 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
/// 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,
private void ApplyLoadedTerrainLocked(
AcDream.App.Streaming.LandblockBuild build,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
var lb = build.Landblock;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return;
@ -7425,13 +7236,20 @@ public sealed class GameWindow : IDisposable
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
// Step 4: drain pending LoadedCells from the worker thread.
// Also collect into a local dict for the BuildingLoader stamping pass below.
var drainedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
while (_pendingCells.TryTake(out var cell))
// Commit the exact visibility-cell snapshot produced by THIS streaming
// completion. The former global ConcurrentBag let one landblock drain
// another landblock's cells and exposed partial builds to the flood.
var committedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
if (build.EnvCells is { } envCellBuild)
{
_cellVisibility.AddCell(cell);
drainedCells[cell.CellId] = cell;
if (envCellBuild.LandblockId != lb.LandblockId)
throw new InvalidOperationException(
$"EnvCell transaction 0x{envCellBuild.LandblockId:X8} was attached to " +
$"landblock 0x{lb.LandblockId:X8}.");
_cellVisibility.CommitLandblock(lb.LandblockId, envCellBuild.VisibilityCells);
foreach (var cell in envCellBuild.VisibilityCells)
committedCells[cell.CellId] = cell;
}
// Compute the per-landblock AABB for frustum culling. XY from the
@ -7493,6 +7311,19 @@ public sealed class GameWindow : IDisposable
// Transform CellStruct vertices from cell-local to world space.
var rot = envCell.Position.Orientation;
var cellOriginWorld = envCell.Position.Origin + origin;
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(rot) *
System.Numerics.Matrix4x4.CreateTranslation(cellOriginWorld);
// Commit live physics-cache state on the render thread with
// the same completed landblock transaction. The worker only
// builds private output; it no longer mutates cell registries.
_physicsDataCache.CacheCellStruct(
envCellId,
envCell,
cellStruct,
physicsCellTransform);
var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
@ -7655,16 +7486,9 @@ public sealed class GameWindow : IDisposable
// surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
if (lbInfo is not null)
{
// Merge: previously-loaded cells + freshly-drained cells.
// drainedCells wins on key collision (it has the same instance
// anyway — both dicts reference the same LoadedCell objects).
var lbStampCells = new System.Collections.Generic.Dictionary<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;
}
// The streaming result now carries the complete landblock cell
// set; no cross-frame merge or global-bag recovery is required.
var lbStampCells = committedCells;
// FIX 2026-05-28: normalize storage key to the cell-prefix
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
@ -7681,15 +7505,13 @@ public sealed class GameWindow : IDisposable
lbInfo, lb.LandblockId, lbStampCells);
}
// Phase A8: finalize EnvCellRenderer's per-landblock instance store.
// Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds,
// populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
if (build.EnvCells is { } renderBuild)
_envCellRenderer?.CommitLandblock(renderBuild);
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
// (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
// and the legacy cell-mesh upload path are retired with InstancedMeshRenderer.
// Cache GfxObj physics data (BSP trees) for the physics engine — this
// loop is physics-only, not renderer-side.
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
@ -7703,10 +7525,6 @@ public sealed class GameWindow : IDisposable
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
}
// Drain _pendingCellMeshes to prevent unbounded accumulation.
// The data is no longer consumed (WB handles EnvCell geometry through
// its own pipeline), but the worker thread still populates this dict.
_pendingCellMeshes.Clear();
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
@ -8202,17 +8020,6 @@ public sealed class GameWindow : IDisposable
observerCx = (int)((cellLb >> 8) & 0xFFu);
observerCy = (int)(cellLb & 0xFFu);
}
// Consume the login-spawn far-recenter flag (network thread → render
// thread): drop the stale startup window + null the region so this
// Tick re-bootstraps the whole window fresh around the spawn origin.
// Same mechanism the outdoor-teleport path uses (line ~5725). Fixes
// the cold-spawn streaming hole (resident-but-never-loaded landblocks
// the RecenterTo diff skipped as already-resident).
if (_pendingForceReloadWindow)
{
_pendingForceReloadWindow = false;
_streamingController.ForceReloadWindow();
}
_streamingController.Tick(observerCx, observerCy, insideDungeon);
// Re-inject persistent entities rescued from unloaded landblocks
@ -13100,7 +12907,7 @@ public sealed class GameWindow : IDisposable
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
// the hydration path (EnvCellLandblockBuildBuilder) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a