Lands the working A8 indoor-rendering and streaming fixes accumulated this session. User has verified these visually to some degree (e.g. lifestone / translucent meshes confirmed fine under the FrontFace flip; bridge / wall / collision regressions confirmed fixed after travel); not every path has been exhaustively gated. The cellar-flap defect remains OPEN and will be solved the retail-faithful way via a dedicated brainstorm (see handoff docs). Rendering core (reviewed, high confidence): - EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of the 80B CPU InstanceData struct the shader never expected — fixes the transform/texture "explosion" for any draw with >1 instance (cells that dedupe to a shared cellGeomId). Real root cause. - WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into same-cull runs with absolute uDrawIDOffset per run). - EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) + WorldEntity.BuildingShellAnchorCellId so building shells scope to their dat-derived building cell instead of rendering everywhere. - RenderOutsideInAcdream (look into buildings from outside) + CollectVisiblePortalBuildings frustum cull of portal bounds. - Sky-when-inside-building + per-cell audit probe + GL-state probe. Streaming / perf (test-covered; not independently code-reviewed this session): - Near/far priority queues so near work wins over far; PromoteToNear carries full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids rebuilding the animated-lookup dict in the hot draw path. Fixes the bridge-not-appearing / missing-walls / broken-collision-after-travel regressions and improves post-transition FPS. Tooling + docs: - tools/A8CellAudit: offline dat cell/portal/building dumper (portals + buildings modes) — reproduces the cellar-flap investigation with no launch. - docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil double-duty finding + the WB-recursive design decision + brainstorm prompt), entity-taxonomy, replan, issue-78 visibility investigation. Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert provisional pos.w clamp, and the probe families are kept (env-var gated, zero cost when off) because the pending option-2 cellar-flap brainstorm needs them. Strip in the option-2 ship commit. Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8 visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
49 KiB
Phase A8 RE-PLAN — Indoor-cell visibility culling (taxonomy-aware integration)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Close issue #78 (outdoor stabs/terrain visible through indoor walls) and the cellar terrain artifact by porting WorldBuilder's stencil-based RenderInsideOut pipeline with an entity taxonomy that correctly distinguishes building shell stabs (cottage walls — must render unconditionally indoors) from outdoor scenery stabs (trees, lampposts — stencil-gated to portal silhouettes).
Architecture: Tag WorldEntity.IsBuildingShell at the LandblockLoader data boundary (sourced from LandBlockInfo.Buildings vs LandBlockInfo.Objects — the dat already carries the distinction). Refactor WbDrawDispatcher.EntitySet from binary IndoorOnly/OutdoorOnly to a three-way IndoorPass (cell mesh + cell statics + building shells) / OutdoorScenery (trees + procedural) / LiveDynamic (server-spawned). Re-wire the render frame with WB's MarkAndPunch-FIRST order so indoor cell depth correctly survives. Stencil-mark only the camera's own cell's exit portals (skip WB Step 5 / 3-stencil-bit for first ship; the cross-cell-portal visibility loss is acceptable).
Tech Stack: C# .NET 10, Silk.NET (OpenGL 4.3 + GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters), xUnit.
Predecessor context (REQUIRED reading before starting):
- docs/research/2026-05-26-a8-revert-handoff.md — full story of the 3-round visual verification failure + reverts
- docs/research/2026-05-26-a8-entity-taxonomy.md — approved fix-shape (3 cross-references converge: retail, WB, and acdream's own GameWindow.cs:5175 comment)
- docs/superpowers/plans/2026-05-25-phase-a8-indoor-cell-visibility-culling.md — original plan; Tasks 1-6 already shipped (dormant in-tree); do NOT re-execute Task 7 as written
Infrastructure preserved (consume as-is):
src/AcDream.App/Rendering/CellVisibility.cs—LoadedCell.PortalPolygons: List<Vector3[]>(populated byBuildLoadedCell)src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs—PortalMeshBuilder.BuildTriangles(pure) +IndoorCellStencilPipeline(GL);UploadPortalMesh/MarkAndPunch/EnableOutdoorPass/DisableStencilalready implement WB's Steps 1+2+4src/AcDream.App/Rendering/Shaders/portal_stencil.vert/.frag— minimal MVP +gl_FragDepth=1.0writersrc/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs—EntitySetenum (will be reshaped) +WalkEntitiesForTesttest helpersrc/AcDream.Core/Rendering/RenderingDiagnostics.cs—ProbeVisibilityEnabledflag (optional, available for diagnostics)
File Structure
| File | What changes | Why |
|---|---|---|
src/AcDream.Core/World/WorldEntity.cs |
Add IsBuildingShell: bool (init-only, default false) |
Carry the dat-level distinction through to render time |
src/AcDream.Core/World/LandblockLoader.cs |
Set IsBuildingShell = true in the info.Buildings loop; leave info.Objects loop unchanged |
Tag at the only point both classes are still distinguishable |
src/AcDream.App/Rendering/GameWindow.cs |
One-line propagation in the stab hydration copy (line 5129-5136) | Preserve flag through dat→runtime hydration |
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs |
Rename EntitySet.IndoorOnly→IndoorPass, OutdoorOnly→OutdoorScenery; add LiveDynamic. Extend WalkEntitiesInto + WalkEntitiesForTest partition logic |
Three-way partition reflecting the actual taxonomy |
src/AcDream.App/Rendering/GameWindow.cs |
Re-wire render frame inside-camera branch with WB-order: MarkAndPunch → IndoorPass → stencil-gated terrain re-draw → OutdoorScenery → LiveDynamic | Integration that respects the entity-taxonomy lesson |
tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs |
Rebuild against new enum values; add IsBuildingShell + LiveDynamic coverage | Lock partition correctness before integration |
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs |
Add tests confirming IsBuildingShell set correctly per source array |
Lock the data-layer guarantee |
docs/ISSUES.md |
Move #78 to Recently closed; document deferred Step 5 / cellar-from-outside artifact | Ship-docs |
CLAUDE.md |
Update A8 paragraph from "REVERTED" → "SHIPPED" with ship summary | Roadmap discipline |
Task R1: WorldEntity.IsBuildingShell flag + LandblockLoader tagging
Files:
-
Modify:
src/AcDream.Core/World/WorldEntity.cs -
Modify:
src/AcDream.Core/World/LandblockLoader.cs:58-87 -
Modify:
src/AcDream.App/Rendering/GameWindow.cs:5129-5137 -
Test:
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs -
R1-S1: Write failing tests for LandblockLoader IsBuildingShell tagging
Append these tests to tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs. (Read the file first to find the existing test class + how to construct a LandBlockInfo mock with Objects and Buildings entries — there's already a setup pattern there; reuse it.)
[Fact]
public void BuildEntitiesFromInfo_TagsBuildingsWithIsBuildingShellTrue()
{
var info = new DatReaderWriter.DBObjs.LandBlockInfo
{
Objects = new System.Collections.Generic.List<DatReaderWriter.Types.Stab>(),
Buildings = new System.Collections.Generic.List<DatReaderWriter.Types.BuildingInfo>
{
new DatReaderWriter.Types.BuildingInfo
{
ModelId = 0x02000123u, // Setup id
Frame = new DatReaderWriter.Types.Frame
{
Origin = new System.Numerics.Vector3(10f, 20f, 30f),
Orientation = System.Numerics.Quaternion.Identity,
},
},
},
};
var entities = AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info);
Assert.Single(entities);
Assert.True(entities[0].IsBuildingShell);
}
[Fact]
public void BuildEntitiesFromInfo_TagsObjectsWithIsBuildingShellFalse()
{
var info = new DatReaderWriter.DBObjs.LandBlockInfo
{
Objects = new System.Collections.Generic.List<DatReaderWriter.Types.Stab>
{
new DatReaderWriter.Types.Stab
{
Id = 0x01000123u, // GfxObj id
Frame = new DatReaderWriter.Types.Frame
{
Origin = new System.Numerics.Vector3(10f, 20f, 30f),
Orientation = System.Numerics.Quaternion.Identity,
},
},
},
Buildings = new System.Collections.Generic.List<DatReaderWriter.Types.BuildingInfo>(),
};
var entities = AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info);
Assert.Single(entities);
Assert.False(entities[0].IsBuildingShell);
}
Note: if the existing tests in this file use a different Stab/BuildingInfo type, mirror that pattern. The field names above match what LandblockLoader.cs already reads at lines 58 and 74.
- R1-S2: Run tests to verify they fail with "IsBuildingShell does not exist"
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockLoaderTests.BuildEntitiesFromInfo_TagsBuildings" --nologo
Expected: BUILD FAILURE with "'WorldEntity' does not contain a definition for 'IsBuildingShell'".
- R1-S3: Add
IsBuildingShelltoWorldEntity
Edit src/AcDream.Core/World/WorldEntity.cs. Add the new property just below ParentCellId (around current line 46):
/// <summary>
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
/// (the dat array that carries building shells: cottage walls, smithy walls,
/// inn walls — every solid building enclosure). False for entities from
/// <c>LandBlockInfo.Objects[]</c> (rocks, fences, lampposts, tree clusters —
/// outdoor scenery placeholders). The two arrays are conflated through
/// hydration today but the dat itself carries the distinction; retail
/// (<c>CLandBlock::init_buildings</c>) and WorldBuilder
/// (<c>SceneryInstance.IsBuilding</c>) both preserve it.
///
/// <para>
/// Read at draw time by <see cref="AcDream.App.Rendering.Wb.WbDrawDispatcher"/>'s
/// <c>IndoorPass</c> partition so building shells render unconditionally
/// when the camera is inside their building (they ARE the indoor walls),
/// not stencil-gated as outdoor scenery would be.
/// </para>
/// </summary>
public bool IsBuildingShell { get; init; }
- R1-S4: Tag Buildings loop in LandblockLoader
Edit src/AcDream.Core/World/LandblockLoader.cs:78-85. Add the IsBuildingShell = true initializer. The Objects loop at lines 62-69 stays unchanged (default false).
Change this block:
var buildingEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
};
To:
var buildingEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
IsBuildingShell = true, // Phase A8: tag at source array boundary
};
- R1-S5: Propagate flag through GameWindow hydration
Edit src/AcDream.App/Rendering/GameWindow.cs:5129-5137. The hydration loop copies fields from e into a fresh WorldEntity; IsBuildingShell is dropped today. Add propagation:
Change this block:
var entity = new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position + worldOffset,
Rotation = e.Rotation,
MeshRefs = meshRefs,
};
To:
var entity = new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position + worldOffset,
Rotation = e.Rotation,
MeshRefs = meshRefs,
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
};
- R1-S6: Run R1 tests to verify they pass
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~LandblockLoaderTests.BuildEntitiesFromInfo_TagsBuildings" --nologo
Expected: 2 tests pass.
- R1-S7: Full build + test to verify no regression
dotnet build -c Debug --nologo
dotnet test --nologo
Expected: build green (0 warnings, 0 errors). Test failures should be within the documented pre-existing ~14-23 flaky window (PhysicsResolveCapture / PhysicsDiagnostics static-leak issues) — no NEW failures attributable to this change.
- R1-S8: Commit
git add src/AcDream.Core/World/WorldEntity.cs src/AcDream.Core/World/LandblockLoader.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs
git commit -m "$(cat <<'EOF'
feat(world): Phase A8 R1 — tag WorldEntity.IsBuildingShell at LandblockLoader
Adds a bool flag at the WorldEntity data layer set by LandblockLoader from
the source dat array: LandBlockInfo.Buildings → true (cottage walls, inn
walls, smithy walls); LandBlockInfo.Objects → false (trees, lampposts,
rocks, hitching posts).
Retail anchor: CLandBlock::init_buildings reads a separate BuildInfo**
array from objects (acclient.h:31893 num_buildings / buildings field;
acclient_2013_pseudo_c.txt:313854 init_buildings entry). WorldBuilder
preserves the same distinction via SceneryInstance.IsBuilding
(StaticObjectRenderManager.cs:334). Today acdream's loader reads both
arrays into the same WorldEntity pool with no tag, destroying the
distinction (the comment at GameWindow.cs:5175 already acknowledges this
gap for scenery suppression). This commit closes the gap.
Render-time consumption arrives in R2 (EntitySet partition refactor).
Two new LandblockLoader tests lock the tagging behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task R2: Reshape WbDrawDispatcher.EntitySet to taxonomy-aware partition
Files:
-
Modify:
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs— enum + 3 partition-logic sites (WalkEntitiesIntoline 360-362 + line 375-377;WalkEntitiesForTestline 1358-1359) -
Modify:
tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs— rebuild against new semantics + add coverage -
R2-S1: Write failing tests for the new partition semantics
Replace the contents of tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs with:
// Phase A8 — verify the WbDrawDispatcher EntitySet partition (taxonomy-aware).
//
// The pure-data WalkEntitiesForTest helper iterates a flat entity list and
// returns the IDs that survive the EntitySet filter + visibleCellIds gate.
//
// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell,
// and NOT live-dynamic (ServerGuid == 0).
// Building shells render unconditionally indoors;
// live-dynamic flows through LiveDynamic instead.
// EntitySet.OutdoorScenery — ParentCellId == null AND !IsBuildingShell
// AND not live-dynamic.
// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items,
// idle doors after animation). Drawn last with
// stencil disabled.
// EntitySet.All — pre-A8 behavior (visibleCellIds gates indoor;
// outdoor entities pass through).
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 WbDrawDispatcherEntitySetTests
{
private static WorldEntity CellEnt(uint id, uint cellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = cellId,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity OutdoorScenery(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = null,
IsBuildingShell = false,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity BuildingShell(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ParentCellId = null,
IsBuildingShell = true,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
private static WorldEntity LiveDynamic(uint id, uint serverGuid) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x02000001u,
ServerGuid = serverGuid,
ParentCellId = null,
IsBuildingShell = false,
MeshRefs = new List<MeshRef> { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
};
[Fact]
public void IndoorPass_IncludesCellEntities()
{
var entities = new List<WorldEntity>
{
CellEnt(0x10000001, 0xA9B40143),
OutdoorScenery(0x10000002),
CellEnt(0x10000003, 0xA9B40144),
};
var visible = new HashSet<uint> { 0xA9B40143u, 0xA9B40144u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(2, result.Count);
Assert.Contains(0x10000001u, result);
Assert.Contains(0x10000003u, result);
Assert.DoesNotContain(0x10000002u, result);
}
[Fact]
public void IndoorPass_IncludesBuildingShells_EvenWithNullParentCellId()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001), // cottage wall
OutdoorScenery(0xC0000002), // tree
CellEnt(0x40000001, 0xA9B40143),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(2, result.Count);
Assert.Contains(0xC0000001u, result); // building shell included
Assert.Contains(0x40000001u, result); // cell entity included
Assert.DoesNotContain(0xC0000002u, result); // tree excluded
}
[Fact]
public void IndoorPass_ExcludesLiveDynamic()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Single(result);
Assert.Contains(0x40000001u, result);
Assert.DoesNotContain(0x10000001u, result); // live-dynamic excluded
}
[Fact]
public void OutdoorScenery_ExcludesBuildingShells()
{
var entities = new List<WorldEntity>
{
BuildingShell(0xC0000001), // cottage wall — excluded
OutdoorScenery(0xC0000002), // tree — included
CellEnt(0x40000001, 0xA9B40143), // cell — excluded
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
Assert.Single(result);
Assert.Contains(0xC0000002u, result);
Assert.DoesNotContain(0xC0000001u, result);
Assert.DoesNotContain(0x40000001u, result);
}
[Fact]
public void OutdoorScenery_ExcludesLiveDynamic()
{
var entities = new List<WorldEntity>
{
OutdoorScenery(0xC0000001),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
Assert.Single(result);
Assert.Contains(0xC0000001u, result);
Assert.DoesNotContain(0x10000001u, result);
}
[Fact]
public void LiveDynamic_IncludesOnlyServerSpawned()
{
var entities = new List<WorldEntity>
{
OutdoorScenery(0xC0000001),
BuildingShell(0xC0000002),
CellEnt(0x40000001, 0xA9B40143),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
LiveDynamic(0x10000002, serverGuid: 0x50000456u),
};
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.LiveDynamic);
Assert.Equal(2, result.Count);
Assert.Contains(0x10000001u, result);
Assert.Contains(0x10000002u, result);
Assert.DoesNotContain(0xC0000001u, result);
Assert.DoesNotContain(0xC0000002u, result);
Assert.DoesNotContain(0x40000001u, result);
}
[Fact]
public void All_MatchesPreA8Behavior()
{
var entities = new List<WorldEntity>
{
CellEnt(0x40000001, 0xA9B40143),
OutdoorScenery(0xC0000001),
BuildingShell(0xC0000002),
LiveDynamic(0x10000001, serverGuid: 0x50000123u),
CellEnt(0x40000002, 0xA9B40999), // not in visibleCellIds
};
var visible = new HashSet<uint> { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.All);
// Pre-A8: visibleCellIds gates indoor entities only; outdoor entities
// (regardless of building/scenery/live-dynamic) pass through.
Assert.Equal(4, result.Count);
Assert.Contains(0x40000001u, result);
Assert.Contains(0xC0000001u, result);
Assert.Contains(0xC0000002u, result);
Assert.Contains(0x10000001u, result);
Assert.DoesNotContain(0x40000002u, result);
}
}
- R2-S2: Run tests to verify they fail with "IndoorPass not found"
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherEntitySetTests" --nologo
Expected: BUILD FAILURE with "'EntitySet' does not contain a definition for 'IndoorPass'" / "'LiveDynamic'".
- R2-S3: Rename + extend the EntitySet enum
Edit src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:69-81. Replace the existing enum block with:
/// <summary>
/// Phase A8 — which subset of entities to walk in a single Draw call.
/// Used to split the indoor-cell visibility pipeline into three passes
/// when the camera is inside an EnvCell.
///
/// Taxonomy reference: docs/research/2026-05-26-a8-entity-taxonomy.md.
/// </summary>
public enum EntitySet
{
/// <summary>Pre-A8 behavior: every entity walked, gated only by
/// the existing <c>ParentCellId ∈ visibleCellIds</c> filter.
/// Used when the camera is OUTSIDE any EnvCell.</summary>
All,
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
/// true, regardless of ParentCellId). These render unconditionally
/// when the camera is inside their building — building shells ARE
/// the indoor walls. Live-dynamic (<c>ServerGuid != 0</c>) is
/// excluded; it flows through <see cref="LiveDynamic"/>.</summary>
IndoorPass,
/// <summary>Outdoor scenery stabs (<c>ParentCellId == null</c>,
/// <c>!IsBuildingShell</c>) plus procedurally-generated scenery.
/// Drawn stencil-gated to portal silhouettes when the camera is
/// inside. Live-dynamic excluded.</summary>
OutdoorScenery,
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
/// player, NPCs, monsters, dropped items, animated and idle doors.
/// Drawn last with stencil disabled so they're depth-tested against
/// everything else but not stencil-clipped.</summary>
LiveDynamic,
}
- R2-S4: Extend the partition logic at the three call sites
Edit src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs. Find the partition predicate at lines 360-362 (inside the landblockVisible == false branch's animated-entity loop):
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
Replace with:
// Phase A8: EntitySet partition (taxonomy-aware).
if (!EntityMatchesSet(entity, set)) continue;
Do the same at lines 375-377 (the main entity-walk loop). Replace:
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
With:
// Phase A8: EntitySet partition (taxonomy-aware).
if (!EntityMatchesSet(entity, set)) continue;
And at lines 1358-1359 in WalkEntitiesForTest, replace:
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
With:
if (!EntityMatchesSet(entity, set)) continue;
Then add the shared predicate as a private static method on WbDrawDispatcher (place it just above WalkEntitiesForTest near line 1344, so all three call sites can reach it):
/// <summary>
/// Phase A8 — entity-taxonomy-aware membership test for the three-way
/// EntitySet partition. See <see cref="EntitySet"/> for the doctrine.
/// </summary>
private static bool EntityMatchesSet(AcDream.Core.World.WorldEntity entity, EntitySet set)
{
if (set == EntitySet.All) return true;
bool isLiveDynamic = entity.ServerGuid != 0;
if (set == EntitySet.LiveDynamic) return isLiveDynamic;
if (isLiveDynamic) return false; // IndoorPass/OutdoorScenery exclude live-dynamic
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
if (set == EntitySet.IndoorPass) return isIndoor;
if (set == EntitySet.OutdoorScenery) return !isIndoor;
return true; // unreachable; defensive default = include
}
- R2-S5: Run R2 tests to verify they pass
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WbDrawDispatcherEntitySetTests" --nologo
Expected: 7 tests pass.
- R2-S6: Run full Core.Tests project to verify no regression
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --nologo
Expected: pass-rate matches the documented pre-existing 14-23 flaky window. No NEW failures attributable to this change. If a test fails referencing IndoorOnly / OutdoorOnly by name (other than the EntitySetTests we just rewrote), update it inline — those are the renamed-enum references.
- R2-S7: Full build to verify App project compiles
dotnet build -c Debug --nologo
Expected: build green.
- R2-S8: Commit
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
git commit -m "$(cat <<'EOF'
feat(render): Phase A8 R2 — WbDrawDispatcher.EntitySet taxonomy partition
Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to
a three-way taxonomy-aware partition:
IndoorPass — cell mesh + cell statics + building shells
(ParentCellId.HasValue OR IsBuildingShell), live-dynamic
excluded
OutdoorScenery — outdoor scenery only (ParentCellId == null AND
!IsBuildingShell), live-dynamic excluded
LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items)
Centralizes the membership predicate in EntityMatchesSet to keep the three
call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY.
R1's IsBuildingShell flag is now consumed at render time. Integration into
the render frame ships in R3.
Tests rebuilt from scratch — 7 cases cover the new partition truth table.
Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the
default EntitySet.All.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task R3: Wire stencil pipeline into the render frame (WB order)
Files:
-
Modify:
src/AcDream.App/Rendering/GameWindow.cs— render frame inside-camera branch (around lines 7079-7170, depending on where the dispatcher call sits after R2) -
Modify:
src/AcDream.App/Rendering/GameWindow.cs—IndoorCellStencilPipelinefield + ctor wiring (find the ctor that owns rendering pipelines; today it sits near theWbDrawDispatcherinstantiation) -
No new tests (GL integration is visual-verification only; the partition logic + stencil math are already covered by existing unit tests from Tasks 1-6)
-
R3-S1: Locate the dispatcher field + add IndoorCellStencilPipeline field
Grep for the existing WbDrawDispatcher field declaration to find the GameWindow ctor's pipeline-init block:
grep -n "_wbDrawDispatcher" src/AcDream.App/Rendering/GameWindow.cs | head -5
Find the line declaring private readonly WbDrawDispatcher? _wbDrawDispatcher; (or similar). Add a sibling field just below it:
private readonly IndoorCellStencilPipeline? _indoorStencilPipeline;
And in the ctor where _wbDrawDispatcher is constructed, instantiate the pipeline. The shader path follows the existing pattern for other shaders in the same ctor (search for portal_stencil in source — the dormant infrastructure already references it):
_indoorStencilPipeline = new IndoorCellStencilPipeline(
_gl,
System.IO.Path.Combine(shaderDir, "portal_stencil.vert"),
System.IO.Path.Combine(shaderDir, "portal_stencil.frag"));
Add a Dispose call in the Dispose() method alongside the other pipeline disposes:
_indoorStencilPipeline?.Dispose();
- R3-S2: Re-wire the render frame inside-camera branch
Find the existing render flow in GameWindow.cs around the cameraInsideCell references (currently lines 7001, 7079, 7118, 7159). The current structure is:
// Step 4: portal visibility — compute BEFORE the UBO upload so
// the indoor flag drives the sun's intensity to zero for dungeons.
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// ... [unchanged: lighting, audio, fog setup] ...
// Sky (skipped when inside)
if (!cameraInsideCell)
{
_skyRenderer?.RenderSky(...);
_particleRenderer?.Draw(..., SkyPreScene);
}
// Terrain
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// Conditional depth clear (depth only, keep terrain color)
if (cameraInsideCell)
_gl!.Clear(ClearBufferMask.DepthBufferBit);
// Animated-id set (unchanged)
HashSet<uint>? animatedIds = ...;
// Single dispatcher call (all entities)
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
// Particles + weather (skipped when inside)
Replace the entity-draw section (from the dispatcher call down through animatedIds build) with this branching version. The terrain draw stays where it is (above the depth clear). After the depth clear:
// L-fix1 (2026-04-28): animated-entity id set (unchanged from
// pre-A8). Required by both the cameraInsideCell branch (to
// route them to LiveDynamic pass) and the outdoor path (where
// it preserves visibility across landblock frustum culling).
HashSet<uint>? animatedIds = null;
if (_animatedEntities.Count > 0)
{
animatedIds = new HashSet<uint>(_animatedEntities.Count);
foreach (var k in _animatedEntities.Keys)
animatedIds.Add(k);
}
if (cameraInsideCell && _indoorStencilPipeline is not null
&& visibility?.CameraCell is not null)
{
// Phase A8: WB RenderInsideOut order.
//
// 1. Terrain has already drawn (color + depth).
// 2. Depth-clear-if-inside has already cleared depth to 1.0
// (above this branch). Punch at portal silhouettes is a
// no-op against that 1.0 baseline — left in for symmetry
// with WB's reference and to handle the unusual case
// where depth-clear is later dropped.
// 3. MarkAndPunch — stencil bit 1 at camera-cell exit portals.
// Step 5 (cross-cell-portal visibility via 3-stencil-bit
// pipeline) is DEFERRED — we mark ONLY the camera's own
// cell's portals, not the BFS-extended VisibleCellIds.
// Trade-off: cross-cell visibility loss (rare visually);
// correctness in the common case (no see-through-wall to
// far-side portal openings).
var cameraCells = new[] { visibility.CameraCell };
_indoorStencilPipeline.UploadPortalMesh(cameraCells);
var viewProjection = camera.View * camera.Projection;
_indoorStencilPipeline.MarkAndPunch(viewProjection);
// 4. IndoorPass — cell mesh + cell statics + building shells
// (R1's IsBuildingShell flag drives the partition).
// Stencil OFF (MarkAndPunch's cleanup restored that).
// Depth test normal; building shells write the wall depth
// that protects the indoor from outdoor visibility.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: WbDrawDispatcher.EntitySet.IndoorPass);
// 5. Stencil-gated outdoor: enable stencil read-only.
_indoorStencilPipeline.EnableOutdoorPass();
// 5a. Re-draw terrain — at portal-silhouette pixels only,
// terrain Z (with the f48c74a -0.01 nudge) wins over the
// punched 1.0 depth. Color writes through window.
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// 5b. Outdoor scenery — same stencil gating.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: WbDrawDispatcher.EntitySet.OutdoorScenery);
// 6. Stencil OFF — live dynamic entities draw freely with
// depth test only.
_indoorStencilPipeline.DisableStencil();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility.VisibleCellIds,
animatedEntityIds: animatedIds,
set: WbDrawDispatcher.EntitySet.LiveDynamic);
}
else
{
// Outdoor path — unchanged from pre-A8: single dispatcher
// call walks every entity with default partition.
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
}
- R3-S3: Build to verify the integration compiles
dotnet build -c Debug --nologo 2>&1 | tail -10
Expected: green. If the camera.View * camera.Projection expression is wrong for the actual camera type in scope, substitute with the correct accessor (read 5 lines above the integration point — the rest of the render frame already uses camera for view/projection access; mirror that style).
- R3-S4: Run full test suite — must stay within the documented flaky window
dotnet test --nologo
Expected: failures within the documented 14-23 flaky window only. No new failures attributable to GameWindow.cs changes (GL integration is not unit-tested, but build alone catches compile errors and pre-existing tests catch any unintended logic changes).
- R3-S5: Commit (no visual verification yet — that's R4)
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "$(cat <<'EOF'
feat(render): Phase A8 R3 — wire stencil pipeline into render frame (WB order)
Replaces the pre-A8 single dispatcher call with the WB RenderInsideOut
order when cameraInsideCell:
1. Terrain draws normally (color + depth)
2. depth-clear-if-inside (depth = 1.0 globally)
3. MarkAndPunch — stencil bit 1 at camera's-own-cell exit portals
4. IndoorPass — cell mesh + cell statics + building shells, stencil OFF
5. EnableOutdoorPass + re-draw terrain + OutdoorScenery, stencil-gated
6. DisableStencil + LiveDynamic, depth-test only
Outdoor (cameraInsideCell == false) path unchanged: single Draw(set: All).
Step 5 (WB's 3-stencil-bit cross-cell-portal pipeline) is DEFERRED — we
mark only the camera's own cell's exit portals via [visibility.CameraCell],
not the BFS-extended VisibleCellIds. Trade-off documented in
docs/research/2026-05-26-a8-entity-taxonomy.md §"open questions".
Visual verification at cottage interior / cottage cellar / inn interior /
dungeon is R4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task R4: Visual verification matrix
Files: none modified in this task; only logs collected and a verification report appended to the plan.
This task ships nothing on its own — it's the visual-gate before R5 (ship docs). The verification scenarios are chosen because each surfaced different bugs in the original A8 attempt and they exercise different parts of the entity taxonomy.
- R4-S1: Build for verification
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo
Expected: green.
- R4-S2: Pre-launch — close any running client; launch
PowerShell:
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($proc) {
$proc.CloseMainWindow() | Out-Null
if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force }
}
Start-Sleep -Seconds 3
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-a8-verify.log"
(If running via Claude, use run_in_background: true so the tool returns immediately.)
- R4-S3: Scenario A — Holtburg cottage interior (ground floor)
Walk +Acdream into one of the Holtburg cottages (any of the cottages near the network portal). Stand in the middle of the room.
Acceptance:
- All walls SOLID — no see-through to outdoor terrain, no see-through to neighboring cottages.
- Outdoor terrain visible ONLY through windows / open doors.
- Player character body visible (no head-backwards, no missing limbs, no flickering on enter).
- No "transparent rectangles around buildings" regression (#100 stays closed).
If the wall opposite a window shows outdoor terrain bleed-through, that's the cross-cell-portal issue (deferred WB Step 5); document but don't treat as a R4 blocker if it's faint and rare.
If walls are TOTALLY MISSING (Round 3 regression) — building shells are not being included in IndoorPass. STOP, investigate EntityMatchesSet and the hydration propagation in R1-S5; do not proceed.
- R4-S4: Scenario B — Holtburg cottage cellar
Walk to a cottage that has a cellar (per #98 saga, the cottage near Holtburg Town's small green; descend the stairs).
Acceptance:
-
Cellar walls + floor + ceiling SOLID.
-
Cellar stairs SOLID — no grass/terrain overlay through the stair geometry from INSIDE (the in-to-out half of the cellar artifact).
-
Known limitation (NOT an R4 blocker): grass/terrain may still be visible through the stair geometry when looking from OUTSIDE the cellar (out-to-in half). That's the deep-cell terrain Z-fight artifact noted in the predecessor handoff — NOT A8 scope; file separately in R5.
-
R4-S5: Scenario C — Holtburg Inn (multi-room indoor)
Walk into the Holtburg inn (the larger building near the town network portal). Move through its rooms.
Acceptance:
-
All inn walls SOLID.
-
Adjacent rooms not visible through walls (no "I can see the door of the next room" regression from Round 2).
-
The inn's interior uses cell mesh more than cottages — confirms the
ParentCellId.HasValuepath in IndoorPass works. -
Furniture (cell statics) visible and properly positioned.
-
R4-S6: Scenario D — A dungeon (portal-entry indoor world)
Pick any reachable dungeon. The closest from Holtburg is Holtburg Sewer (if mapped on this server) — otherwise the network portal can teleport to any dungeon zone.
Acceptance:
- Corridor walls SOLID.
- Adjacent corridors not visible through walls.
- Lighting reads as indoor (sun zeroed, indoor ambient applied).
- No outdoor stab/terrain leak.
Dungeons exercise cell mesh + cell statics ONLY (no building shells; dungeons aren't building-baked). If dungeons regress while cottages work, the bug is in the IndoorPass partition predicate's interaction with cell-mesh entities — not the IsBuildingShell flag.
- R4-S7: Graceful close + collect log
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($proc) {
$proc.CloseMainWindow() | Out-Null
if (-not $proc.WaitForExit(5000)) { $proc | Stop-Process -Force }
}
Append verification notes to the plan or to a follow-up handoff doc:
-
Scenario A result: PASS / FAIL with notes
-
Scenario B result: PASS / FAIL with notes
-
Scenario C result: PASS / FAIL with notes
-
Scenario D result: PASS / FAIL with notes
-
R4-S8: Gate decision
-
If all four scenarios PASS or have only documented known limitations → proceed to R5.
-
If ANY scenario fails an acceptance criterion → STOP, do not commit ship docs. Open a new investigation (
/investigateskill) to triage the failure. The taxonomy fix is correct in principle; failures here are integration-detail bugs (GL state, ordering, missed entity class) that need narrow fixes rather than a re-revert.
Task R5: Ship docs (close #78, update CLAUDE.md, file deferrals)
Files:
-
Modify:
docs/ISSUES.md— move #78 to Recently closed; file new ISSUES for the known limitations -
Modify:
CLAUDE.md— update the A8 paragraph from "REVERTED" → "SHIPPED" -
R5-S1: Update ISSUES.md
Read docs/ISSUES.md and find the #78 entry (currently OPEN). Move it to "Recently closed" with a commit ref:
**#78 — Outdoor stabs/buildings visible through the rendered floor** — CLOSED by R3 (commit <SHA>). Phase A8 re-plan ported WB's RenderInsideOut stencil pipeline with a corrected entity taxonomy (WorldEntity.IsBuildingShell flag distinguishing building shells from outdoor scenery stabs). Visual-verified at cottage interior, cottage cellar, Holtburg inn, dungeon.
In OPEN issues, file the two known limitations as new issues (assign next sequential numbers — read the doc to find the highest current ID and add to it):
## #102 — Far-side portal visibility through walls (WB Step 5 deferral)
**Status:** OPEN (low priority; first ship of A8 deferred this).
**Description:** When standing inside a multi-room building, looking at a wall between rooms, portals on the FAR side of the room (e.g. a doorway opening to outdoors on the other side of the wall) may have their silhouette stencil-marked by Phase A8. This lets outdoor terrain leak through the wall at that silhouette. The first-ship approximation in A8 R3 stencil-marks ONLY the camera's own cell's exit portals (not BFS-extended VisibleCellIds), which AVOIDS the leak in most cases but loses cross-cell-portal visibility.
**Acceptance:** Inside Holtburg Inn looking at the wall between two rooms, no visible terrain or scenery shows through. WB Step 5's 3-stencil-bit cross-building pipeline is the reference fix.
**Files:**
- `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` — currently single-bit stencil; would extend to bits 1+2.
- `src/AcDream.App/Rendering/GameWindow.cs` — render frame would gain a per-far-portal pass.
## #103 — Outdoor-to-indoor cellar terrain Z-fight (out-to-in artifact)
**Status:** OPEN (low priority; pre-existing).
**Description:** Looking from OUTSIDE a cottage cellar at the stair geometry from above, grass/terrain may overlap the stair triangles. Pre-existing; not addressed by A8 (no stencil work runs when the camera is outside). #100's 1cm terrain Z nudge is insufficient because cellar geometry sits multiple meters below terrain Z — depth-precision artifacts persist at oblique angles.
**Acceptance:** From outside a cottage, looking at the cellar entrance, stair geometry reads as solid stone (no grass overlay) regardless of camera angle.
**Files:** likely a deeper terrain-occlusion mechanism (per-cell terrain mask, or proper outdoor portal culling) — beyond the scope of A8.
- R5-S2: Update CLAUDE.md A8 paragraph
Read CLAUDE.md and find the current A8 paragraph. It currently begins "Phase A8 — REVERTED 2026-05-26..." (or similar). Replace with a SHIPPED summary along the lines of:
**Phase A8 — Indoor-cell visibility culling — SHIPPED 2026-05-26.** Closes
issue #78. Five commits across the re-plan:
- R1: `WorldEntity.IsBuildingShell` flag set at `LandblockLoader` from
`LandBlockInfo.Buildings` vs `LandBlockInfo.Objects` — the dat-level
distinction acdream's loader was destroying.
- R2: `WbDrawDispatcher.EntitySet` reshape to taxonomy-aware partition
(`IndoorPass` / `OutdoorScenery` / `LiveDynamic`).
- R3: Render frame re-wired with WB's RenderInsideOut order — MarkAndPunch
before indoor draw; stencil-gated outdoor re-draw; live dynamic last
with stencil disabled. Camera's-own-cell-portals-only approximation
(WB Step 5 deferred as #102).
- Tasks 1-6 infrastructure (`PortalPolygons`, `IndoorCellStencilPipeline`,
`portal_stencil` shaders, dormant `EntitySet` enum) shipped 2026-05-25
and consumed as-is.
Visual-verified at Holtburg cottage interior, cottage cellar (in-to-out
half), Holtburg Inn (multi-room), and a dungeon. Two deferrals filed as
#102 (cross-cell-portal far-side visibility) and #103 (cellar terrain
Z-fight from outside).
Full re-plan: [docs/superpowers/plans/2026-05-26-phase-a8-replan.md](docs/superpowers/plans/2026-05-26-phase-a8-replan.md).
Taxonomy reference: [docs/research/2026-05-26-a8-entity-taxonomy.md](docs/research/2026-05-26-a8-entity-taxonomy.md).
Revert handoff (now historical): [docs/research/2026-05-26-a8-revert-handoff.md](docs/research/2026-05-26-a8-revert-handoff.md).
Find any other A8 references in CLAUDE.md (e.g. the "Currently working toward" line if it says A8) and update them to reflect ship.
- R5-S3: Commit ship docs
git add docs/ISSUES.md CLAUDE.md
git commit -m "$(cat <<'EOF'
ship(render): Phase A8 — indoor-cell visibility culling SHIPPED
Closes #78 (outdoor stabs/terrain visible through indoor walls). Files
#102 (cross-cell-portal far-side visibility, WB Step 5 deferral) and
#103 (cellar terrain Z-fight from outside; pre-existing, not A8 scope).
Visual-verified at Holtburg cottage interior, cottage cellar, Holtburg
Inn, and dungeon.
Architecture: entity taxonomy partition (WorldEntity.IsBuildingShell
tagged at LandblockLoader) drives WbDrawDispatcher.EntitySet into a
three-way IndoorPass / OutdoorScenery / LiveDynamic split. Render frame
follows WB's RenderInsideOut order with the camera's-own-cell-portals-only
approximation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Self-review (run after writing this plan, before execution)
- R1 covers: the IsBuildingShell flag, loader tagging, hydration propagation, two LandblockLoader tests. ✓
- R2 covers: enum reshape, partition predicate, three call sites updated, seven tests covering the truth table. ✓
- R3 covers: pipeline field + ctor wiring, render frame branching, both inside + outside paths. ✓
- R4 covers: four visual scenarios chosen to surface different bug classes (cottage = building shell + cell mesh; cellar = building shell + sloped; inn = multi-room cell mesh; dungeon = cell mesh only). ✓
- R5 covers: ISSUES.md move + two deferral filings, CLAUDE.md ship summary, commit. ✓
Cross-task type consistency check: IsBuildingShell (bool, init-only on WorldEntity) is used consistently in R1 (declaration + set), R2 (EntityMatchesSet predicate), and R3 (no direct reference; goes through the predicate). EntitySet.IndoorPass / OutdoorScenery / LiveDynamic names match across R2 (definition) and R3 (consumption). _indoorStencilPipeline is the field name introduced in R3-S1 and referenced through R3-S2. No drift detected.
Placeholder scan: no "TBD" / "implement later" / "similar to" / generic-validation strings. Every code step has actual code. Every command step has an actual command.
Spec coverage: original handoff's pickup prompt Phase 2 expected R1-R6 tasks. This plan ships R1 (entity distinguisher) + R2 (EntitySet partition) + R3 (render frame re-wire) + R4 (visual verification matrix) + R5 (ship docs). R6 (deferred Step 5 etc.) is folded into R5 as filed issues #102 + #103 — no separate task needed since "decide to defer" was already approved in the entity-taxonomy fix-shape.