acdream/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md
Erik 1fc6c0fd69 plan: Phase 1 indoor cell rendering diagnostics
Eight bite-sized tasks:
1. RenderingDiagnostics static class (mirrors PhysicsDiagnostics pattern)
2. Unit tests (cascade + IsEnvCellId rows)
3. DebugVM mirror properties
4. DebugPanel "Indoor rendering" checkbox group
5. WbMeshAdapter [indoor-upload] probes (requested + completed via pending set)
6. WbDrawDispatcher [indoor-walk] + [indoor-cull] probes
7. WbDrawDispatcher [indoor-lookup] + [indoor-xform] probes
8. Build + visual capture + match captured data to hypothesis H1-H6

Plan ends with research note documenting captured data + hypothesis,
which becomes the input to Phase 2's spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:13:53 +02:00

964 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (H1H6 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;
/// <summary>
/// 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.
///
/// <para>
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
/// common case — flipping it cascades to all five probe flags.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
/// </para>
/// </summary>
public static class RenderingDiagnostics
{
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-walk]</c> 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 <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
/// </summary>
public static bool ProbeIndoorWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
/// line per visible cell entity per second: render-data hit/miss,
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
/// </summary>
public static bool ProbeIndoorLookupEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
/// silently returned null (hypothesis H1).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
/// </summary>
public static bool ProbeIndoorUploadEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
/// line per visible cell entity per second: cell-geometry SetupPart's
/// composed world matrix translation. Disambiguates transform
/// double-apply (hypothesis H5).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
/// </summary>
public static bool ProbeIndoorXformEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
/// cull bugs (hypothesis H3).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
/// </summary>
public static bool ProbeIndoorCullEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// Master toggle. Reading reflects the AND of all five flags
/// (true only when every probe is on). Writing cascades — setting
/// to <see langword="true"/> turns ALL five flags on; setting to
/// <see langword="false"/> turns ALL five off.
/// </summary>
public static bool IndoorAll
{
get => ProbeIndoorWalkEnabled
&& ProbeIndoorLookupEnabled
&& ProbeIndoorUploadEnabled
&& ProbeIndoorXformEnabled
&& ProbeIndoorCullEnabled;
set
{
ProbeIndoorWalkEnabled = value;
ProbeIndoorLookupEnabled = value;
ProbeIndoorUploadEnabled = value;
ProbeIndoorXformEnabled = value;
ProbeIndoorCullEnabled = value;
}
}
/// <summary>
/// Helper for probe call sites. Returns <see langword="true"/> when
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
/// in the 8×8 landblock grid (0x00010x0040).
/// </summary>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorWalkEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_WALK</c>).
/// </summary>
public bool ProbeIndoorWalk
{
get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorLookupEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_LOOKUP</c>).
/// </summary>
public bool ProbeIndoorLookup
{
get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorUploadEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_UPLOAD</c>).
/// </summary>
public bool ProbeIndoorUpload
{
get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorXformEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_XFORM</c>).
/// </summary>
public bool ProbeIndoorXform
{
get => RenderingDiagnostics.ProbeIndoorXformEnabled;
set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorCullEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_CULL</c>).
/// </summary>
public bool ProbeIndoorCull
{
get => RenderingDiagnostics.ProbeIndoorCullEnabled;
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
/// five indoor probes together.
/// </summary>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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
/// <summary>
/// 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).
/// </summary>
private readonly HashSet<ulong> _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) <noreply@anthropic.com>
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
/// <summary>
/// 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.
/// </summary>
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
private int _indoorProbeFrameCounter;
private const int IndoorProbeRateLimitFrames = 30;
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per cellId. Caller must already have checked that an indoor
/// probe flag is enabled.
/// </summary>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.