diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index b934ade..10a14f4 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -1089,6 +1089,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
}
+ ///
+ /// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only
+ /// entities whose ParentCellId is in , plus
+ /// outdoor-style entities matching the EntitySet partition. Used by
+ /// the indoor render branch to scope rendering to the camera-buildings'
+ /// cells.
+ ///
+ /// Mirrors the existing visibleCellIds-based Draw but with an
+ /// explicit cell list (not the BFS-derived visibility set). The semantic
+ /// difference is at the caller: cellIds = the camera-buildings' EnvCellIds,
+ /// not the portal BFS result. The dispatcher's internal logic is identical
+ /// — it filters indoor entities by membership in the provided set.
+ ///
+ public void Draw(
+ ICamera camera,
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> landblockEntries,
+ IReadOnlyCollection cellIds,
+ FrustumPlanes? frustum = null,
+ uint? neverCullLandblockId = null,
+ HashSet? animatedEntityIds = null,
+ EntitySet set = EntitySet.All)
+ {
+ // Adapt IReadOnlyCollection → HashSet for the existing path.
+ // If the caller already passed a HashSet, avoid re-wrapping.
+ HashSet cellIdSet = cellIds is HashSet hs ? hs : new HashSet(cellIds);
+ Draw(camera, landblockEntries,
+ frustum: frustum,
+ neverCullLandblockId: neverCullLandblockId,
+ visibleCellIds: cellIdSet,
+ animatedEntityIds: animatedEntityIds,
+ set: set);
+ }
+
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
IndexCount: g.IndexCount,
FirstIndex: g.FirstIndex,
@@ -1400,6 +1435,34 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
return output;
}
+ ///
+ /// Phase A8 RR5 (2026-05-26): pure-data walk for the explicit cellIds
+ /// overload. Used by RR7's IndoorPass to render only the camera-buildings'
+ /// cells (instead of the visibility-derived set).
+ ///
+ /// Indoor entities (ParentCellId set) gated by membership in
+ /// . Building shells (IsBuildingShell) pass
+ /// unconditionally when == IndoorPass. Outdoor
+ /// scenery is excluded by the EntitySet partition (no cell-list gate
+ /// needed — EntityMatchesSet handles it).
+ ///
+ public static List WalkEntitiesForTestByCellIds(
+ IEnumerable entities,
+ IReadOnlyCollection cellIds,
+ EntitySet set)
+ {
+ var result = new List();
+ foreach (var entity in entities)
+ {
+ if (!EntityMatchesSet(entity, set)) continue;
+ if (entity.MeshRefs.Count == 0) continue;
+ if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
+ continue;
+ result.Add(entity.Id);
+ }
+ return result;
+ }
+
public void Dispose()
{
if (_disposed) return;
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs
new file mode 100644
index 0000000..5b52f57
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs
@@ -0,0 +1,90 @@
+// Phase A8 RR5 — verify WbDrawDispatcher.WalkEntitiesForTestByCellIds,
+// the pure-data companion to the new Draw(cellIds:) production overload.
+//
+// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit
+// membership in cellIds. Building shells (IsBuildingShell) always pass.
+// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass.
+
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public class WbDrawDispatcherCellIdsOverloadTests
+{
+ private static WorldEntity CellEnt(uint id, uint cellId) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ ParentCellId = cellId,
+ MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) },
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ };
+
+ private static WorldEntity OutdoorScenery(uint id) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ ParentCellId = null,
+ IsBuildingShell = false,
+ MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) },
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ };
+
+ private static WorldEntity BuildingShell(uint id) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ ParentCellId = null,
+ IsBuildingShell = true,
+ MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) },
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ };
+
+ [Fact]
+ public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells()
+ {
+ var entities = new List
+ {
+ CellEnt(0x40000001u, 0xA9B40150u), // in listed cells
+ CellEnt(0x40000002u, 0xA9B40151u), // in listed cells
+ CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list
+ BuildingShell(0xC0000001u), // always included (IsBuildingShell)
+ OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list
+ };
+ var cellIds = new HashSet { 0xA9B40150u, 0xA9B40151u };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
+ entities, cellIds, set: WbDrawDispatcher.EntitySet.IndoorPass);
+
+ Assert.Equal(3, result.Count);
+ Assert.Contains(0x40000001u, result);
+ Assert.Contains(0x40000002u, result);
+ Assert.Contains(0xC0000001u, result);
+ Assert.DoesNotContain(0x40000003u, result);
+ Assert.DoesNotContain(0xC0000002u, result);
+ }
+
+ [Fact]
+ public void WalkEntitiesByCellIds_EmptyCellList_StillIncludesBuildingShells()
+ {
+ var entities = new List
+ {
+ CellEnt(0x40000001u, 0xA9B40150u),
+ BuildingShell(0xC0000001u),
+ };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds(
+ entities, new HashSet(), set: WbDrawDispatcher.EntitySet.IndoorPass);
+
+ // Cell entity dropped (no cells in list); building shell still passes.
+ Assert.Single(result);
+ Assert.Contains(0xC0000001u, result);
+ }
+}