From f8d0499d8bd952f2e3029df7fb01bf019d46eaae Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 11:13:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20RR4=20=E2=80=94=20?= =?UTF-8?q?wire=20BuildingRegistry=20into=20landblock=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadedCell.BuildingId (init + internal setter) — set exactly once at landblock load time by BuildingLoader; null when the cell isn't part of any building (outdoor surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings). GameWindow landblock-load path: builds BuildingRegistry from LandBlockInfo.Buildings; stamps each cell's BuildingId; stores the registry on _buildingRegistries[landblockId] (GameWindow-level dict) for render-frame lookups. Note: LoadedLandblock is AcDream.Core.World (a sealed record) — adding an App-type field there would violate Code Structure Rule #2, so the registry is stored in a new GameWindow-level dictionary instead. Cleanup wired in both removeTerrain lambdas (OnLoad + OnResize paths). drainedCells dict: the existing _pendingCells drain loop is extended to also build a local CellId→LoadedCell dict; BuildingLoader.Build uses this dict for the stamping pass so no second iteration is needed. New BuildingLoaderTest verifies the stamping path. 5 BuildingLoader tests total (4 from RR3 + 1 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/CellVisibility.cs | 13 ++++++ src/AcDream.App/Rendering/GameWindow.cs | 29 +++++++++++++ .../Rendering/Wb/BuildingLoader.cs | 13 ++++-- .../Rendering/Wb/BuildingLoaderTests.cs | 42 +++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index b4debbc..d3cf6f1 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -70,6 +70,19 @@ public sealed class LoadedCell /// /// public List PortalPolygons = new(); + + /// + /// Phase A8 (2026-05-26): the building this cell belongs to, if any. + /// Set exactly once by immediately after + /// LandblockLoader produces the cells. Null when the cell isn't part of + /// any building (outdoor surface cells; dungeon cells not enumerated in + /// LandBlockInfo.Buildings). + /// + /// Used by the render frame to derive the camera-buildings set + /// via + /// and route IndoorPass cell scoping. + /// + public uint? BuildingId { get; internal set; } } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5869071..cd65887 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -150,6 +150,14 @@ public sealed class GameWindow : IDisposable // to _cellVisibility on the render thread in ApplyLoadedTerrain. private readonly System.Collections.Concurrent.ConcurrentBag _pendingCells = new(); + // Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock + // id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain + // time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame + // indoor-cell scoping. Entries are removed in the removeTerrain callbacks. + // Only touched on the render thread — no lock required. + private readonly System.Collections.Generic.Dictionary + _buildingRegistries = new(); + /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each @@ -1833,6 +1841,7 @@ public sealed class GameWindow : IDisposable _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); + _buildingRegistries.Remove(id); // Phase A8 }); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -5665,8 +5674,13 @@ 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(); while (_pendingCells.TryTake(out var cell)) + { _cellVisibility.AddCell(cell); + drainedCells[cell.CellId] = cell; + } // Compute the per-landblock AABB for frustum culling. XY from the // landblock's world origin + 192 footprint. Z from the terrain vertex @@ -5826,6 +5840,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). + if (lbInfo is not null) + { + _buildingRegistries[lb.LandblockId] = + AcDream.App.Rendering.Wb.BuildingLoader.Build( + lbInfo, lb.LandblockId, drainedCells); + } } // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via @@ -8894,6 +8922,7 @@ public sealed class GameWindow : IDisposable _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); + _buildingRegistries.Remove(id); // Phase A8 }); _streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame; diff --git a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs index ad50bed..cca29f3 100644 --- a/src/AcDream.App/Rendering/Wb/BuildingLoader.cs +++ b/src/AcDream.App/Rendering/Wb/BuildingLoader.cs @@ -26,7 +26,7 @@ namespace AcDream.App.Rendering.Wb; /// unloaded cell). In production, streaming loads all cells for a landblock /// before runs, so the dict is always complete. /// -/// LoadedCell.BuildingId stamping is wired in RR4, not here. +/// LoadedCell.BuildingId is stamped here in RR4 after reg.Add(building). /// /// Retail references: /// docs/research/named-retail/acclient.h:32035 (BuildInfo) and @@ -128,9 +128,14 @@ public static class BuildingLoader }; reg.Add(building); - // NOTE: LoadedCell.BuildingId stamping is wired in RR4 (requires - // an internal setter on LoadedCell that doesn't exist yet). This - // comment is the placeholder called out in the plan's RR3-S11. + // Step 4: stamp BuildingId on each cell (Option C — both directions + // O(1)). The internal setter on LoadedCell.BuildingId is accessible + // because this class lives in the same assembly (AcDream.App). + foreach (var cellId in envCellIds) + { + if (cellsByCellId.TryGetValue(cellId, out var cell)) + cell.BuildingId = building.BuildingId; + } } return reg; diff --git a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs index 8294351..9152231 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs @@ -85,4 +85,46 @@ public class BuildingLoaderTests foreach (var b in reg.All()) ids.Add(b.BuildingId); Assert.Equal(new SortedSet { 1, 2 }, ids); // sequential 1, 2 } + + [Fact] + public void Build_StampsLoadedCellBuildingId() + { + // Fixture: minimal LoadedCell instances representing 2 cottage cells. + var cell150 = new AcDream.App.Rendering.LoadedCell + { + CellId = 0xA9B40150u, + Portals = new List(), + PortalPolygons = new List(), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + LocalBoundsMin = new Vector3(-5, -5, -5), + LocalBoundsMax = new Vector3(5, 5, 5), + ClipPlanes = new List(), + }; + var cell151 = new AcDream.App.Rendering.LoadedCell + { + CellId = 0xA9B40151u, + Portals = new List(), + PortalPolygons = new List(), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + LocalBoundsMin = new Vector3(-5, -5, -5), + LocalBoundsMax = new Vector3(5, 5, 5), + ClipPlanes = new List(), + }; + var cells = new Dictionary + { + { 0xA9B40150u, cell150 }, + { 0xA9B40151u, cell151 }, + }; + + var info = MakeInfo((0x02000123u, new[] { 0x0150u, 0x0151u })); + var reg = BuildingLoader.Build(info, 0xA9B40000u, cells); + + Assert.Equal(1, reg.Count); + var b = System.Linq.Enumerable.First(reg.All()); + // Both cells stamped with the building id: + Assert.Equal(b.BuildingId, cell150.BuildingId); + Assert.Equal(b.BuildingId, cell151.BuildingId); + } }