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>
41 KiB
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
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 (0x0001–0x0040).
/// </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
ProbeBuildingmirror 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 ofDebugVM.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:
IncrementRefCount— emitsrequestedon the first call for an EnvCell id (gated by the existing_metadataPopulated.Add(id)first-call check).Tick()— emitscompletedwhen WB'sStagedMeshDatadrain produces anObjectMeshDatawhoseObjectIdis in our pending-EnvCell set.
- Step 1: Add the pending-EnvCell tracking field +
usingstatement
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] requestedinIncrementRefCount
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] completedinTick
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 +
usingstatement
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.mdwith a Phase 2 section, OR - Write a new spec
docs/superpowers/specs/YYYY-MM-DD-indoor-cell-rendering-phase2-fix-design.mdtargeting 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 buildclean.dotnet testclean (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.