diff --git a/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md new file mode 100644 index 0000000..53c35ef --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md @@ -0,0 +1,964 @@ +# Indoor Cell Rendering Fix — Phase 1 Diagnostics Implementation Plan + +> **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:** Add five toggleable diagnostic probes that pinpoint where the EnvCell rendering chain breaks, so Phase 2's fix can target the actual failure point. + +**Architecture:** Single `RenderingDiagnostics` static class in `AcDream.Core.Rendering` exposes five bool flags + a master toggle (env-var-initialized, runtime-settable). DebugVM mirrors them as live-toggle properties; DebugPanel exposes them as checkboxes. Probe call sites in `WbMeshAdapter` and `WbDrawDispatcher` emit one structured `[indoor-*]` line per event when the corresponding flag is on. The Holtburg Inn floor-missing bug is the test case — log output identifies which of six hypotheses (H1–H6 in the spec) the failure matches. + +**Tech Stack:** C# .NET 10, xUnit (test framework), Silk.NET OpenGL (rendering), Chorizite.OpenGLSDLBackend (WB ObjectMeshManager). + +**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-indoor-cell-rendering-fix-design.md) + +--- + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | NEW | Static class with five `bool` properties + master toggle. Env-var read at startup; runtime-settable. | +| `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` | NEW | Verify default values and get/set behavior of the diagnostic flags. | +| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | MODIFY | Add five mirror properties that forward to `RenderingDiagnostics`. | +| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | MODIFY | Add an "Indoor rendering" subsection in `DrawDiagnostics` with six checkboxes. | +| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY | Emit `[indoor-upload] requested` on first `IncrementRefCount` for an EnvCell id; emit `[indoor-upload] completed` in `Tick()` when WB's staged drain produces that id's `ObjectMeshData`. | +| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFY | Emit `[indoor-walk]` + `[indoor-cull]` in `WalkVisibleEntities` per cell entity; emit `[indoor-lookup]` and `[indoor-xform]` in `DrawAccumulated` per cell-entity render-data lookup + composed transform. | + +--- + +## Task 1: Create `RenderingDiagnostics` static class + +**Files:** +- Create: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` + +- [ ] **Step 1: Write the file** + +The class mirrors `AcDream.Core.Physics.PhysicsDiagnostics` exactly — same env-var-init pattern, same get/set, same XML comments style. Five individual probe flags + one `IndoorAll` master. The master setter cascades to all five. + +```csharp +using System; + +namespace AcDream.Core.Rendering; + +/// +/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell +/// rendering pipeline. Initialized from env vars at process start; +/// flippable at runtime via the DebugPanel mirror. Log call sites read +/// these statics so a checkbox toggle takes effect on the next frame +/// without relaunching. +/// +/// +/// Mirrors the L.2a +/// pattern. The master toggle is the user's +/// common case — flipping it cascades to all five probe flags. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md. +/// +/// +public static class RenderingDiagnostics +{ + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-walk] line per visible cell entity per second: + /// entity id, world position, parent cell id, landblock visible flag, + /// AABB-visible flag, "in visible cells" flag, drew flag. + /// Initial state from ACDREAM_PROBE_INDOOR_WALK=1. + /// + public static bool ProbeIndoorWalkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-lookup] + /// line per visible cell entity per second: render-data hit/miss, + /// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies. + /// Initial state from ACDREAM_PROBE_INDOOR_LOOKUP=1. + /// + public static bool ProbeIndoorLookupEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbMeshAdapter emits two lines per EnvCell id: + /// [indoor-upload] requested on first IncrementRefCount and + /// [indoor-upload] completed when WB's staged drain produces + /// its ObjectMeshData. Missing "completed" lines indicate WB + /// silently returned null (hypothesis H1). + /// Initial state from ACDREAM_PROBE_INDOOR_UPLOAD=1. + /// + public static bool ProbeIndoorUploadEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-xform] + /// line per visible cell entity per second: cell-geometry SetupPart's + /// composed world matrix translation. Disambiguates transform + /// double-apply (hypothesis H5). + /// Initial state from ACDREAM_PROBE_INDOOR_XFORM=1. + /// + public static bool ProbeIndoorXformEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-cull] line per cell entity that gets culled, with + /// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates + /// cull bugs (hypothesis H3). + /// Initial state from ACDREAM_PROBE_INDOOR_CULL=1. + /// + public static bool ProbeIndoorCullEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// Master toggle. Reading reflects the AND of all five flags + /// (true only when every probe is on). Writing cascades — setting + /// to turns ALL five flags on; setting to + /// turns ALL five off. + /// + public static bool IndoorAll + { + get => ProbeIndoorWalkEnabled + && ProbeIndoorLookupEnabled + && ProbeIndoorUploadEnabled + && ProbeIndoorXformEnabled + && ProbeIndoorCullEnabled; + set + { + ProbeIndoorWalkEnabled = value; + ProbeIndoorLookupEnabled = value; + ProbeIndoorUploadEnabled = value; + ProbeIndoorXformEnabled = value; + ProbeIndoorCullEnabled = value; + } + } + + /// + /// Helper for probe call sites. Returns when + /// the low 16 bits of are ≥ 0x0100 — the AC + /// convention for EnvCell (indoor) cells, as opposed to outdoor cells + /// in the 8×8 landblock grid (0x0001–0x0040). + /// + public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug` +Expected: 0 errors, 0 warnings. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs +git commit -m "$(cat <<'EOF' +feat(diagnostics): RenderingDiagnostics static class for indoor probes + +Five toggleable bool flags + master IndoorAll cascade, mirroring the +L.2a PhysicsDiagnostics pattern. Env vars at startup, runtime-settable +via DebugPanel mirrors (added next task). Probe call sites and DebugVM +wiring follow in subsequent tasks. + +Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Unit-test `RenderingDiagnostics` + +**Files:** +- Create: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using AcDream.Core.Rendering; +using Xunit; + +namespace AcDream.Core.Tests.Rendering; + +public sealed class RenderingDiagnosticsTests +{ + [Fact] + public void IndoorAll_True_TurnsAllFlagsOn() + { + // Reset all flags off first to make the test deterministic + // regardless of env-var state on the test runner. + RenderingDiagnostics.ProbeIndoorWalkEnabled = false; + RenderingDiagnostics.ProbeIndoorLookupEnabled = false; + RenderingDiagnostics.ProbeIndoorUploadEnabled = false; + RenderingDiagnostics.ProbeIndoorXformEnabled = false; + RenderingDiagnostics.ProbeIndoorCullEnabled = false; + + RenderingDiagnostics.IndoorAll = true; + + Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled); + Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled); + Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled); + Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled); + Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled); + Assert.True(RenderingDiagnostics.IndoorAll); + } + + [Fact] + public void IndoorAll_False_TurnsAllFlagsOff() + { + RenderingDiagnostics.IndoorAll = true; // start from all-on + RenderingDiagnostics.IndoorAll = false; + + Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled); + Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled); + Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled); + Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled); + Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled); + Assert.False(RenderingDiagnostics.IndoorAll); + } + + [Fact] + public void IndoorAll_OneOff_ReadsAsFalse() + { + RenderingDiagnostics.IndoorAll = true; + RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off + Assert.False(RenderingDiagnostics.IndoorAll); + } + + [Theory] + [InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid + [InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix + [InlineData(0x00000100ul, true)] // indoor cell minimum + [InlineData(0x00000105ul, true)] // typical Holtburg Inn interior + [InlineData(0xA9B40105ul, true)] // indoor with landblock prefix + [InlineData(0xA9B401FFul, true)] // indoor near top of range + public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected) + { + Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id)); + } +} +``` + +- [ ] **Step 2: Run tests — expect failure on first build** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RenderingDiagnostics" -c Debug --nologo` + +Expected: Build green (Task 1 already implemented the class). All 7 tests pass (1 cascade-on + 1 cascade-off + 1 partial-off + 4 IsEnvCellId rows). + +If any test fails, the implementation in Task 1 has a bug — go back and fix. + +- [ ] **Step 3: Commit** + +```bash +git add tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +git commit -m "$(cat <<'EOF' +test(diagnostics): RenderingDiagnostics cascade + IsEnvCellId rows + +Covers the master IndoorAll cascade (both directions) and the IsEnvCellId +helper's 0x0100 boundary check across outdoor cells, indoor cells, and +landblock-prefixed forms. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Mirror `RenderingDiagnostics` into `DebugVM` + +**Files:** +- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` + +- [ ] **Step 1: Read DebugVM and find the existing `ProbeBuilding` mirror block** + +Find the `ProbeBuilding` property (around line 270) — that's an existing live-mirror to `PhysicsDiagnostics.ProbeBuildingEnabled`. New mirrors go immediately AFTER `ProbeAutoWalk` (next property in the file), in a new clearly-commented block. + +- [ ] **Step 2: Add `using AcDream.Core.Rendering;` at the top of `DebugVM.cs`** + +If the using statement is already present, skip. Otherwise insert alphabetically after `using AcDream.Core.Physics;`. + +- [ ] **Step 3: Append the five mirror properties to the file** + +Find the closing brace of the last existing property block (after `ProbeAutoWalk` or the last `Probe*` property). Insert this block before the class's closing brace: + +```csharp + // ── Indoor rendering diagnostics (2026-05-19) ─────────────────── + // Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles + // take effect on the next render frame without relaunching. + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorWalkEnabled + /// (env var ACDREAM_PROBE_INDOOR_WALK). + /// + public bool ProbeIndoorWalk + { + get => RenderingDiagnostics.ProbeIndoorWalkEnabled; + set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorLookupEnabled + /// (env var ACDREAM_PROBE_INDOOR_LOOKUP). + /// + public bool ProbeIndoorLookup + { + get => RenderingDiagnostics.ProbeIndoorLookupEnabled; + set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorUploadEnabled + /// (env var ACDREAM_PROBE_INDOOR_UPLOAD). + /// + public bool ProbeIndoorUpload + { + get => RenderingDiagnostics.ProbeIndoorUploadEnabled; + set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorXformEnabled + /// (env var ACDREAM_PROBE_INDOOR_XFORM). + /// + public bool ProbeIndoorXform + { + get => RenderingDiagnostics.ProbeIndoorXformEnabled; + set => RenderingDiagnostics.ProbeIndoorXformEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorCullEnabled + /// (env var ACDREAM_PROBE_INDOOR_CULL). + /// + public bool ProbeIndoorCull + { + get => RenderingDiagnostics.ProbeIndoorCullEnabled; + set => RenderingDiagnostics.ProbeIndoorCullEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all + /// five indoor probes together. + /// + public bool ProbeIndoorAll + { + get => RenderingDiagnostics.IndoorAll; + set => RenderingDiagnostics.IndoorAll = value; + } +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug` +Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; new properties compile. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +git commit -m "$(cat <<'EOF' +feat(debugvm): mirror RenderingDiagnostics indoor probes + +Live-toggle wrappers for the five indoor-rendering probe flags plus the +ProbeIndoorAll master cascade. Pattern matches existing ProbeResolve / +ProbeCell / ProbeBuilding / ProbeAutoWalk mirrors so a checkbox flip in +the DebugPanel takes effect on the next frame. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Expose probes in `DebugPanel` Diagnostics group + +**Files:** +- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` + +- [ ] **Step 1: Find `DrawDiagnostics(IPanelRenderer r)` method** + +Open the file. Find the method at approximately line 226. The existing pattern reads probe values into locals at the top of the method, then conditionally re-assigns through checkboxes. The new indoor probes follow the same shape, appended after the last existing probe checkbox. + +- [ ] **Step 2: Read the locals + checkboxes at the bottom of the existing block** + +Find the line that says `if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;` or similar last existing probe checkbox in `DrawDiagnostics`. New checkboxes go immediately AFTER this line, before the method's closing brace. + +- [ ] **Step 3: Insert the new checkboxes** + +Before the closing brace of `DrawDiagnostics`, insert: + +```csharp + + // ── Indoor rendering diagnostics (2026-05-19) ─────────────── + // Pinpoint where the EnvCell rendering chain breaks for + // hypothesis-driven Phase 2 fix. Spec: + // docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md + r.Separator(); + r.Text("Indoor rendering (envCell):"); + + bool probeIndoorAll = _vm.ProbeIndoorAll; + bool probeIndoorWalk = _vm.ProbeIndoorWalk; + bool probeIndoorLookup = _vm.ProbeIndoorLookup; + bool probeIndoorUpload = _vm.ProbeIndoorUpload; + bool probeIndoorXform = _vm.ProbeIndoorXform; + bool probeIndoorCull = _vm.ProbeIndoorCull; + + if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll; + if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk; + if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup; + if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload; + if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform; + if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull; +``` + +Note: `r.Separator()` and `r.Text(string)` are the existing `IPanelRenderer` API methods used elsewhere in the file. If they don't exist, drop those two lines (the checkboxes still work standalone). + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug` +Expected: 0 errors. + +If `r.Separator()` / `r.Text()` aren't on `IPanelRenderer`, the build will fail. Remove those two lines and re-build. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +git commit -m "$(cat <<'EOF' +feat(debugpanel): "Indoor rendering" probe checkboxes + +Six checkboxes (ALL master + five individual probes) in the existing +DrawDiagnostics block. Toggling flips the corresponding +RenderingDiagnostics.Probe* flag live via DebugVM forwarding. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Instrument `WbMeshAdapter` with `[indoor-upload]` probes + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` + +The upload probe has TWO emission points: +1. `IncrementRefCount` — emits `requested` on the first call for an EnvCell id (gated by the existing `_metadataPopulated.Add(id)` first-call check). +2. `Tick()` — emits `completed` when WB's `StagedMeshData` drain produces an `ObjectMeshData` whose `ObjectId` is in our pending-EnvCell set. + +- [ ] **Step 1: Add the pending-EnvCell tracking field + `using` statement** + +Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`). + +Find the field declarations near the top of the class (around line 34 — `_metadataPopulated`). Add immediately after: + +```csharp + /// + /// EnvCell ids we've requested via PrepareMeshDataAsync but not yet + /// seen completion for in Tick(). Used by the [indoor-upload] probe + /// to log requested + completed pairs. Cleared per completion; + /// missing completions after a few seconds indicate WB silently + /// returned null (hypothesis H1 in the design spec). + /// + private readonly HashSet _pendingEnvCellRequests = new(); +``` + +- [ ] **Step 2: Emit `[indoor-upload] requested` in `IncrementRefCount`** + +Find the `IncrementRefCount(ulong id)` method (around line 116). Inside the `if (_metadataPopulated.Add(id))` block, immediately AFTER the `_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);` line, add: + +```csharp + // [indoor-upload] requested probe — only for EnvCell ids. + if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled) + { + _pendingEnvCellRequests.Add(id); + Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}"); + } +``` + +- [ ] **Step 3: Emit `[indoor-upload] completed` in `Tick`** + +Find the `Tick()` method (around line 167). Replace the existing drain loop: + +```csharp + while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) + { + _meshManager.UploadMeshData(meshData); + } +``` + +with: + +```csharp + while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) + { + // [indoor-upload] completed probe — check BEFORE upload so we + // see what WB actually produced (vertex counts, parts) before + // any post-upload mutation. + bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled + && _pendingEnvCellRequests.Remove(meshData.ObjectId); + + var renderData = _meshManager.UploadMeshData(meshData); + + if (isPendingEnvCell) + { + int parts = meshData.SetupParts?.Count ?? 0; + bool hasGeom = meshData.EnvCellGeometry is not null; + int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0; + bool uploadOk = renderData is not null; + Console.WriteLine( + $"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " + + $"isSetup={meshData.IsSetup} parts={parts} " + + $"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " + + $"uploadOk={uploadOk}"); + } + } +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +git commit -m "$(cat <<'EOF' +feat(wb): [indoor-upload] probe for EnvCell mesh requests + completions + +Instruments WbMeshAdapter at two sites: +- IncrementRefCount: on first call for an EnvCell id (low 16 bits ≥ + 0x0100), tag the id in _pendingEnvCellRequests and log + [indoor-upload] requested. +- Tick: when WB's StagedMeshData drains an ObjectMeshData whose + ObjectId matches a pending EnvCell, log [indoor-upload] completed + with parts count, EnvCellGeometry vertex count, and upload result. + +Missing "completed" lines after "requested" identify hypothesis H1 +(WB silently returns null from PrepareEnvCellMeshData). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Instrument `WbDrawDispatcher` walk + cull probes + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` + +The `WalkVisibleEntities` method (around line 280) does landblock visibility, per-entity AABB cull, and the `visibleCellIds` filter. Cell entities (entities whose `MeshRefs[0].GfxObjId` low-16-bits ≥ 0x0100) need probes at three decision sites: passed-all, culled-by-aabb, culled-by-visibleCellIds. + +To rate-limit, maintain a per-cellId last-log frame counter as a class-level field. + +- [ ] **Step 1: Add the rate-limit tracking field + `using` statement** + +Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`). + +Find the class field declarations. Add: + +```csharp + /// + /// Per-cell-entity last-log frame number for rate-limiting the + /// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull] + /// probes. Defaults to 30 frames at 30Hz = 1 sec. + /// + private readonly Dictionary _lastIndoorProbeFrame = new(); + private int _indoorProbeFrameCounter; + private const int IndoorProbeRateLimitFrames = 30; + + /// + /// Returns true at most once per + /// frames per cellId. Caller must already have checked that an indoor + /// probe flag is enabled. + /// + private bool ShouldEmitIndoorProbe(ulong cellId) + { + if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last) + || _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames) + { + _lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter; + return true; + } + return false; + } +``` + +- [ ] **Step 2: Bump the frame counter at the top of `Draw(...)`** + +Find the `Draw` method (around line 339). At its very top, after the existing `_shader.Use();` line, add: + +```csharp + _indoorProbeFrameCounter++; +``` + +- [ ] **Step 3: Replace the per-entity filter block in `WalkVisibleEntities`** + +Find the per-entity loop in `WalkVisibleEntities` (around lines 313-335). The current shape (simplified): + +```csharp + foreach (var entity in entry.Entities) + { + if (entity.MeshRefs.Count == 0) continue; + + if (entity.ParentCellId.HasValue && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)) + continue; + + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + if (entity.AabbDirty) entity.RefreshAabb(); + if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) + continue; + } + + result.EntitiesWalked++; + for (int i = 0; i < entity.MeshRefs.Count; i++) + scratch.Add((entity, i, entry.LandblockId)); + } +``` + +Replace the entire `foreach (var entity in entry.Entities)` body with this instrumented version: + +```csharp + foreach (var entity in entry.Entities) + { + if (entity.MeshRefs.Count == 0) continue; + + // Detect cell entity for indoor probes — first MeshRef.GfxObjId + // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute; + // result reused for all four probe checks below. + ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId; + bool isCellEntity = RenderingDiagnostics.IsEnvCellId(cellProbeId); + + bool cellInVis = !(entity.ParentCellId.HasValue + && visibleCellIds is not null + && !visibleCellIds.Contains(entity.ParentCellId.Value)); + if (!cellInVis) + { + if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled + && ShouldEmitIndoorProbe(cellProbeId)) + { + Console.WriteLine( + $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + + $"reason=visibleCellIds-miss " + + $"parentCell=0x{entity.ParentCellId!.Value:X8}"); + } + continue; + } + + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + bool aabbVisible = true; + if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) + { + if (entity.AabbDirty) entity.RefreshAabb(); + aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax); + } + + if (!aabbVisible) + { + if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled + && ShouldEmitIndoorProbe(cellProbeId)) + { + Console.WriteLine( + $"[indoor-cull] cellEnt=0x{entity.Id:X8} " + + $"reason=frustum " + + $"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " + + $"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})"); + } + continue; + } + + // Passed all filters — emit walk probe. + if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled + && ShouldEmitIndoorProbe(cellProbeId)) + { + Console.WriteLine( + $"[indoor-walk] cellEnt=0x{entity.Id:X8} " + + $"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " + + $"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + + $"meshRef0=0x{cellProbeId:X8} " + + $"meshRefCount={entity.MeshRefs.Count} " + + $"landblockVisible=true aabbVisible=true cellInVis=true"); + } + + result.EntitiesWalked++; + for (int i = 0; i < entity.MeshRefs.Count; i++) + scratch.Add((entity, i, entry.LandblockId)); + } +``` + +Important: `ShouldEmitIndoorProbe(cellProbeId)` is intentionally called only once per probe-decision-site per cellId, so each cellId emits at most ONE line per frame across all four probe sites (whichever fires first). + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; the new field + helper compile; the instrumented loop builds cleanly. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +git commit -m "$(cat <<'EOF' +feat(dispatcher): [indoor-walk] + [indoor-cull] probes + +Instruments WalkVisibleEntities to identify whether cell entities (first +MeshRef.GfxObjId low-16-bits ≥ 0x0100) pass all visibility filters or +get culled. Three emission paths: + +- [indoor-cull] reason=visibleCellIds-miss — when the ParentCellId + filter rejects the entity. +- [indoor-cull] reason=frustum — when AABB frustum cull rejects. +- [indoor-walk] — when the entity passes all filters and reaches the + draw list. + +Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via +_lastIndoorProbeFrame dictionary. Bumped from Draw()'s top. + +Disambiguates hypothesis H3 (cull bug — cell entity dropped before +draw). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Instrument `WbDrawDispatcher` lookup + xform probes + +**Files:** +- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` + +These probes fire deeper in the per-MeshRef draw loop, where the render-data lookup happens and the `IsSetup` branch composes per-part transforms. The dispatcher's per-MeshRef body is around line 590-627. + +- [ ] **Step 1: Find the per-MeshRef body and the IsSetup branch** + +Open the file. Find the line `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` (or similar TryGetRenderData lookup inside the per-MeshRef draw loop). The relevant block is the if/else at line 607 (the `IsSetup` branch). + +- [ ] **Step 2: Add the `[indoor-lookup]` probe at the lookup site** + +Find the line that fetches the renderData (likely `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` or equivalent). Immediately AFTER that lookup and BEFORE the existing null/miss handling at line 595 (`if (diag) _meshesMissing++; continue;`), insert: + +```csharp + // [indoor-lookup] probe — emit once per cell entity per sec. + ulong _lookupCellId = (ulong)gfxObjId; + if (RenderingDiagnostics.IsEnvCellId(_lookupCellId) + && RenderingDiagnostics.ProbeIndoorLookupEnabled + && ShouldEmitIndoorProbe(_lookupCellId)) + { + bool hit = renderData is not null; + bool isSetup = hit && renderData!.IsSetup; + int partCount = isSetup ? renderData!.SetupParts.Count : 0; + + int partsHit = 0, partsMiss = 0; + if (isSetup) + { + foreach (var (partId, _) in renderData!.SetupParts) + { + if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++; + else partsMiss++; + } + } + + bool hasEnvCellGeom = isSetup + && renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0); + + Console.WriteLine( + $"[indoor-lookup] cellId=0x{_lookupCellId:X8} " + + $"hit={hit} isSetup={isSetup} partCount={partCount} " + + $"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}"); + } +``` + +Note: this probe emits BEFORE the null-renderData early-`continue`, so a null lookup still emits `hit=false`. That's intentional — it tells us if the lookup itself failed (hypothesis H1 fallout). + +- [ ] **Step 3: Add the `[indoor-xform]` probe inside the IsSetup branch** + +Find the `if (renderData.IsSetup && renderData.SetupParts.Count > 0)` block (line 607 in current code). Inside the `foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)` loop, AFTER the `var model = ComposePartWorldMatrix(...)` line, insert: + +```csharp + // [indoor-xform] probe — only for the cell's synthetic + // geometry part (bit 32 set, per WB's PrepareEnvCellMeshData + // line 1247). One line per cell per sec. + if ((partGfxObjId & 0x1_0000_0000UL) != 0 + && RenderingDiagnostics.ProbeIndoorXformEnabled + && ShouldEmitIndoorProbe(partGfxObjId)) + { + Console.WriteLine( + $"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " + + $"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " + + $"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " + + $"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " + + $"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})"); + } +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` +Expected: 0 errors. + +- [ ] **Step 5: Test (existing tests, sanity)** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Rendering" --no-build --nologo` +Expected: All Rendering tests (including new RenderingDiagnosticsTests) pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +git commit -m "$(cat <<'EOF' +feat(dispatcher): [indoor-lookup] + [indoor-xform] probes + +Instruments the per-MeshRef draw loop in WbDrawDispatcher: + +- [indoor-lookup]: per cell entity, dumps render-data hit/miss, + IsSetup, parts count, and a partsHit/partsMiss tally over the + SetupParts. Disambiguates hypothesis H2 (WB produces empty + ObjectRenderData with zero parts) and H6 (dispatcher fails to + traverse Setup). + +- [indoor-xform]: only fires for the cell's synthetic geometry part + (the SetupPart whose GfxObjId has bit 32 set, per WB's + PrepareEnvCellMeshData cellGeomId convention). Logs the three + composed transform translations: entityWorld, meshRef.PartTransform, + partTransform, and the final composed matrix translation. Disambiguates + hypothesis H5 (transform double-apply — composedT lands at 2 × + cellOrigin). + +Rate-limited via existing _lastIndoorProbeFrame map. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Build + visual capture procedure + +**Files:** none modified. Build verification + runtime data capture. + +- [ ] **Step 1: Full solution build** + +Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10` +Expected: 0 errors, 0 warnings. All projects compile. + +- [ ] **Step 2: Run full test suite** + +Run: `dotnet test AcDream.slnx -c Debug --nologo --no-build 2>&1 | tail -15` +Expected: New RenderingDiagnostics tests pass. Pre-existing failures in `DispatcherToMovementIntegrationTests`, `BSPStepUpTests`, and `MotionInterpreterTests` (8 total) remain — those are unrelated to this work. No NEW failures. + +- [ ] **Step 3: Gracefully close any prior AcDream.App instance** + +```powershell +$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue +if ($proc) { + $proc | ForEach-Object { $_.CloseMainWindow() | Out-Null } + $proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } } + Start-Sleep -Seconds 3 +} +``` + +- [ ] **Step 4: Launch with all indoor probes enabled** + +```powershell +$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" +$env:ACDREAM_DEVTOOLS = "1" +$env:ACDREAM_PROBE_INDOOR_ALL = "1" +$logPath = "launch.log" +Remove-Item $logPath -ErrorAction SilentlyContinue +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath +``` + +Run this in the background (the launching tool supports `run_in_background: true`). + +- [ ] **Step 5: User reproduces the bug** + +In the running client: +- Wait until in-world at Holtburg (8-12 s after launch). +- Walk to Holtburg Inn (north of spawn — Fispur's Foodstuffs is visible). +- Stand at the doorway. Then step inside. Look at the floor. +- Walk around the inn interior. +- Close the client window (graceful close — close button, NOT taskkill). + +- [ ] **Step 6: Grep the log for probe output** + +```bash +grep -E "\[indoor-" launch.log | head -100 +``` + +Expected: a mix of `[indoor-upload] requested`, `[indoor-upload] completed`, `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` lines for the Holtburg Inn cell IDs (0xA9B40100-ish range). + +- [ ] **Step 7: Identify which hypothesis matches** + +Compare the captured log against the hypothesis table in the spec (§3 of `2026-05-19-indoor-cell-rendering-fix-design.md`): + +| Hypothesis | Probe pattern in log | +|---|---| +| H1 — WB silently returns null | `[indoor-upload] requested` lines exist but NO matching `completed` lines for cell ids | +| H2 — Empty batches | `[indoor-upload] completed ... cellGeomVerts=0` | +| H3 — Cull bug | `[indoor-cull]` lines for cell entity ids with `reason=visibleCellIds-miss` | +| H4 — Double-spawn | `[indoor-lookup] partCount=N` where N includes static object IDs that ALSO appear in the entity walk — cross-check against `[indoor-walk]` lines | +| H5 — Transform double-apply | `[indoor-xform] composedT` translation roughly 2× the cell's known world origin | +| H6 — MeshRefs structure | `[indoor-lookup] hit=true isSetup=true partCount>0 partsHit=0` (all parts missing) | + +- [ ] **Step 8: Document the captured data + matched hypothesis** + +Create a short investigation note at `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md` summarizing: +- The exact `[indoor-*]` log lines captured (or a representative subset). +- The matched hypothesis number. +- A one-line proposed fix sketch. + +This file will be referenced by Phase 2's spec. + +- [ ] **Step 9: Commit the capture note** + +```bash +git add docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md +git commit -m "$(cat <<'EOF' +docs(research): Phase 1 indoor probe capture — identifies hypothesis HX + +[Replace HX with the matched hypothesis number, and summarize the +captured log evidence in 1-2 sentences.] + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 10: Hand off to Phase 2 design** + +The captured data is now the input to Phase 2's design. Either: +- Amend `docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md` with a Phase 2 section, OR +- Write a new spec `docs/superpowers/specs/YYYY-MM-DD-indoor-cell-rendering-phase2-fix-design.md` targeting the identified hypothesis. + +The plan for Phase 2 follows the standard brainstorming → writing-plans → executing-plans flow. + +--- + +## Acceptance Criteria + +- [x] All eight tasks complete + committed. +- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged). +- [ ] Probe captured at Holtburg Inn produces enough log evidence to identify which of H1-H6 is the root cause. +- [ ] Capture note written and committed. +- [ ] Phase 2 design follow-up spec started.