fix(render): Phase A8 RR7.1 — stamp BuildingId on cells loaded across multiple frames

RR7 visual gate (2026-05-27) revealed the indoor branch NEVER fired even
when the strict gate's PointInCell + non-null CameraCell hit: 17,748
inside=True frames, 0 branch=indoor decisions. Root cause: RR4 wired
BuildingLoader.Build with the per-frame drainedCells dict — cells that
streamed in on earlier frames (the common case, since cells arrive
asynchronously over many frames after the landblock-info completion)
were not in drainedCells, so the BFS short-circuited and the registry's
EnvCellIds set was systematically incomplete. Cells loaded ahead of
lbInfo arrival never got their BuildingId stamped.

Fix has two parts:

1. CellVisibility.AllLoadedCells — new public IReadOnlyDictionary
   exposing the existing private _cellLookup. BuildingLoader.Build at
   landblock-info-arrival now walks the full cell set, not just this
   frame's drain.

2. _pendingCells drain loop — late-stamps BuildingId on each arriving
   cell if its landblock's BuildingRegistry already exists. Covers cells
   that arrive AFTER the registry-build pass.

Together these handle all four timing cases:
  - Cells loaded before lbInfo arrives  → stamped in BuildingLoader.Build
  - Cells loaded with lbInfo (same frame) → stamped in BuildingLoader.Build
  - Cells loaded after lbInfo arrives    → stamped in drain loop
  - lbInfo never arrives (LB has no info) → registry never built, cells
                                            stay at BuildingId == null
                                            (intended — flow through outdoor
                                            render path)

Probe data from the failed gate launch confirmed cell 0xA9B40150
(cottage idx=6 cellar from the #98 saga) was reached as the camera cell
with visN=16 visible neighbours, but BuildingId stayed null. This fix
gets the indoor branch fired in that scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 11:45:45 +02:00
parent 3d28d701a2
commit a1a3e0ee3e
2 changed files with 34 additions and 11 deletions

View file

@ -176,6 +176,18 @@ public sealed class CellVisibility
/// <summary>Full-ID lookup for O(1) neighbour resolution during BFS.</summary>
private readonly Dictionary<uint, LoadedCell> _cellLookup = new();
/// <summary>
/// Phase A8 RR7.1 (2026-05-27): read-only view of every loaded cell, keyed
/// by full 32-bit cell id. Used by <see cref="Wb.BuildingLoader"/> at
/// landblock-info-arrival time so its BFS can reach cells that streamed
/// in on earlier frames (not just the per-frame drain). Without this view
/// the registry's <c>EnvCellIds</c> set was systematically short, leaving
/// <see cref="LoadedCell.BuildingId"/> unset on the cells the camera
/// actually enters — which silently routed indoor frames through the
/// outdoor branch.
/// </summary>
public IReadOnlyDictionary<uint, LoadedCell> AllLoadedCells => _cellLookup;
/// <summary>The cell the camera was in during the last <see cref="ComputeVisibility"/> call.</summary>
private LoadedCell? _lastCameraCell;

View file

@ -5696,12 +5696,21 @@ public sealed class GameWindow : IDisposable
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
// 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>();
// Phase A8 RR7.1 (2026-05-27): also late-stamp BuildingId on each
// arriving cell if the landblock's BuildingRegistry already exists
// (cells loaded after the registry-build pass at line ~5876). Cells
// arriving BEFORE the registry are stamped by BuildingLoader.Build
// itself via the AllLoadedCells dict.
while (_pendingCells.TryTake(out var cell))
{
_cellVisibility.AddCell(cell);
drainedCells[cell.CellId] = cell;
uint cellLbId = cell.CellId & 0xFFFF0000u;
if (_buildingRegistries.TryGetValue(cellLbId, out var existingReg))
{
var bs = existingReg.GetBuildingsContainingCell(cell.CellId);
if (bs.Count > 0)
cell.BuildingId = bs[0].BuildingId;
}
}
// Compute the per-landblock AABB for frustum culling. XY from the
@ -5863,18 +5872,20 @@ public sealed class GameWindow : IDisposable
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
// Phase A8 (2026-05-26): build per-landblock BuildingRegistry from
// LandBlockInfo.Buildings, stamping LoadedCell.BuildingId for each cell
// in a building's cell set. Uses the already-drained drainedCells dict
// (LoadedCells registered this frame) so stamping and registry build
// happen in the same render-thread pass — no extra dat reads required.
// Cells without a building stay at BuildingId == null (outdoor surface
// cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
// Phase A8 (2026-05-26, fixed 2026-05-27 RR7.1): build per-landblock
// BuildingRegistry from LandBlockInfo.Buildings, stamping
// LoadedCell.BuildingId for each cell in a building's cell set.
// Uses _cellVisibility.AllLoadedCells (every cell loaded so far,
// not just the per-frame drain) so the BFS can reach cells that
// streamed in on earlier frames. Cells arriving AFTER this build
// pass get stamped at drain time (see _pendingCells loop above).
// Cells without a building stay at BuildingId == null (outdoor
// surface cells; dungeon cells not in LandBlockInfo.Buildings).
if (lbInfo is not null)
{
_buildingRegistries[lb.LandblockId] =
AcDream.App.Rendering.Wb.BuildingLoader.Build(
lbInfo, lb.LandblockId, drainedCells);
lbInfo, lb.LandblockId, _cellVisibility.AllLoadedCells);
}
}