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

41 KiB
Raw Blame History

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


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.

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
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

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
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:

    // ── 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
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:


        // ── 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
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:

    /// <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:

            // [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:

        while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
        {
            _meshManager.UploadMeshData(meshData);
        }

with:

        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
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:

    /// <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:

        _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):

            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:

            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
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:

            // [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:

                    // [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
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
$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
$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

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
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

  • 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.