# Indoor Cell Rendering Fix — Phase 2 Design

**Status:** Brainstormed 2026-05-19. Awaiting user review.
**Scope:** Surface the silent failure in WB's `PrepareEnvCellMeshData` for 26/123 Holtburg cells, then implement the targeted fix.
**Predecessor:** Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) shipped the five `[indoor-*]` probes that confirmed hypothesis H1.
**Capture evidence:** `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`.

---

## 1. What we know

Phase 1's `ACDREAM_PROBE_INDOOR_ALL=1` capture at Holtburg `0xA9B4` proved:

- 123 EnvCells requested via `WbMeshAdapter.IncrementRefCount` → only **97 complete**.
- **26 cells** silently fail. They get `[indoor-upload] requested` but never `[indoor-upload] completed`.
- The dispatcher then tries to draw them, `TryGetRenderData` returns null, draw is silently skipped → user sees **missing floor**.
- The first interior cell `0xA9B40100` (likely the inn entry or another major building anchor) is among the 26.

The smoking gun is in WB's [`ObjectMeshManager.PrepareMeshData`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs):

```csharp
catch (Exception ex) {
    _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
    return null;
}
```

WB logs the exception via its injected `_logger`. But [`WbMeshAdapter.cs:71`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:71) constructs `ObjectMeshManager` with `NullLogger<ObjectMeshManager>.Instance` — so the log goes to `/dev/null`. The exception type and message are lost.

## 2. Solution — three components

### Component 1 — Exception-surfacing wrap

Capture the `Task<ObjectMeshData?>` returned by `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` and attach a continuation that, for EnvCell IDs only, logs the failure cause.

Three logged outcomes:

- **Task faulted** → `[indoor-upload] FAILED cellId=0x... exception=<TypeName>: <Message> stack=[<top 3 frames>]`. Unwrap `AggregateException.InnerException` for cleaner output.
- **Task succeeded with null result** → `[indoor-upload] NULL_RESULT cellId=0x...`. WB's deliberate null-return path (e.g., `ResolveId` returned empty, type was `Unknown`).
- **Task succeeded with non-null result** → no extra log. The existing `Tick()` drain already emits `[indoor-upload] completed`.

The continuation:
- Runs on `TaskScheduler.Default` (`ThreadPool`) so it doesn't block the render thread.
- Only attached for EnvCell IDs (gated by `RenderingDiagnostics.IsEnvCellId(id)`) when `ProbeIndoorUploadEnabled` is true — zero cost when off.
- Captures `cellId` (a `ulong` value) only; no instance closure leakage.
- Truncates stack trace to top 3 frames.

Concrete code shape:

```csharp
if (_metadataPopulated.Add(id))
{
    PopulateMetadata(id);
    var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);

    if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
    {
        _pendingEnvCellRequests.Add(id);
        Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");

        ulong cellId = id;
        _ = prepTask.ContinueWith(t =>
        {
            if (t.IsFaulted && t.Exception is not null)
            {
                var ex = t.Exception.InnerException ?? t.Exception;
                var stack = (ex.StackTrace ?? "").Split('\n')
                    .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
                Console.WriteLine(
                    $"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
                    $"exception={ex.GetType().Name}: {ex.Message} " +
                    $"stack=[{string.Join(" | ", stack)}]");
            }
            else if (t.IsCompletedSuccessfully && t.Result is null)
            {
                Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}");
            }
        }, TaskScheduler.Default);
    }
}
```

`using System.Linq;` and `using System.Threading.Tasks;` may need adding (likely already present).

### Component 2 — Capture procedure

Standard launch:

```powershell
$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log
```

Walk into Holtburg Inn, walk into nearby buildings whose cells were on the missing-26 list (`0xA9B40100`, `0xA9B40111`, etc.). Close gracefully.

Analyze:

```powershell
Get-Content launch.log |
    Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' } |
    Select-Object -Unique
```

Expected output: a per-cell list of distinct exception types or null-return signals. Most cells likely share 1–3 root causes.

### Component 3 — Targeted fix (shape unknown until Component 2 captures)

Once Component 2 reveals the exception type + message, the fix is one localized code change. Likely shapes:

| Captured cause | Fix shape |
|---|---|
| Texture decode `Exception` (e.g. `KeyNotFoundException` on surface ID) | Guard at `WbMeshAdapter.PopulateMetadata` or pre-validate surfaces; possibly patch WB fork. |
| `KeyNotFoundException` for missing `Environment` / `CellStruct` | Log + skip cell with a sentinel render-data; report which dat is stale. |
| `NullReferenceException` in `PrepareCellStructMeshData` | Add null guard at the specific call site. |
| WB internal logic bug | Fork patch to WB. |
| `NULL_RESULT` (ResolveId returned empty / type was Unknown) | Investigate dat file integrity; possibly user needs a dat update. |

The fix is one or two code edits, lands as a single commit, and is followed by a re-launch verifying:
- `[indoor-upload] FAILED` / `NULL_RESULT` lines disappear for the previously-failing cells.
- `[indoor-upload] completed` appears for those cells.
- Visual verification: floor renders in Holtburg Inn.

---

## 3. Edge cases

| Scenario | Behavior |
|---|---|
| Probe toggled off mid-session | Continuation still emits if attached at request time. Acceptable — capturing the cause once matters more than honoring runtime toggle. |
| Continuation fires after adapter disposed | Harmless console write on dying process. No memory leak; closure captures only the `ulong` cellId. |
| Same cell requested twice | `_metadataPopulated.Add(id)` guards; continuation attaches exactly once. Re-streaming after Remove+Add keeps the sticky set. First failure is what we want. |
| Cancellation | `t.IsCanceled` is neither `IsFaulted` nor `IsCompletedSuccessfully`. Continuation silently skips. Acceptable — cancellation isn't a failure cause. |
| `Task.Result` on faulted task | Re-throws AggregateException. Our gate `else if (t.IsCompletedSuccessfully && t.Result is null)` ensures we never read Result without a clean success state. |
| WB's `_logger.LogError` for the same exception | WbMeshAdapter passes `NullLogger` — WB's log goes nowhere. Our continuation is what surfaces it. Discussed below. |

**Why not just inject a real logger into `ObjectMeshManager`?** Could replace `NullLogger<ObjectMeshManager>.Instance` with a real logger that writes to `Console.WriteLine`. Tradeoff:

- Real logger: simpler, leverages WB's existing `_logger.LogError` call → catches GfxObj + Setup + EnvCell failures.
- Our continuation: scoped to EnvCell IDs only → less noise.

Going with the continuation approach because:
1. The probe flag is already in place.
2. Phase 2 is targeted at EnvCells.
3. Real-logger would emit thousands of GfxObj/Setup log lines during landblock streaming, drowning the EnvCell signal.

We can revisit if a future debugging session calls for broader visibility.

---

## 4. Testing strategy

### Unit tests

None for Component 1 — the continuation is straight wiring around an async API; the logic is "if faulted, log; if null result, log." Testing requires either mocking `Task<ObjectMeshData?>` (low value) or running a real WB instance (impractical in unit tests).

### Visual verification (end-to-end)

Component 2's capture procedure is the verification mechanism:

1. Build green.
2. Launch with probe flag on, walk into Holtburg.
3. Confirm `[indoor-upload] FAILED` or `NULL_RESULT` lines appear for ~26 cells.
4. Apply Component 3's fix.
5. Re-launch, re-walk Holtburg.
6. **Acceptance:** previously-failing cells now produce `[indoor-upload] completed` lines AND the user can see the floor in Holtburg Inn.

---

## 5. What's NOT in this phase

- Tightening `IsEnvCellId` false-positives (flagged in Phase 1 capture note). Deferred — doesn't block Phase 2 since the upload probe gates on the correct path.
- Cell collision symptoms (no wall collision when exiting, weird open-air collisions). Separate investigation phase.
- Stab-leak-through-walls (Phase 1 Task 3). Deferred.
- Broader WB logger injection for GfxObj/Setup failures. Open if we ever want broader diagnostic visibility.

---

## 6. Acceptance criteria

- [ ] `WbMeshAdapter.IncrementRefCount` captures the prep task and attaches a continuation for EnvCell IDs.
- [ ] Continuation logs `[indoor-upload] FAILED cellId=0x... exception=<TypeName>: <Message> stack=[...]` for faulted tasks.
- [ ] Continuation logs `[indoor-upload] NULL_RESULT cellId=0x...` for clean-null returns.
- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
- [ ] Capture launched, FAILED/NULL_RESULT lines appear for the previously-missing cells, distinct causes identified.
- [ ] Component 3 fix designed and implemented for each distinct cause.
- [ ] Re-capture confirms `[indoor-upload] completed` appears for cells previously missing.
- [ ] Visual verification: floor renders in Holtburg Inn.
- [ ] Roadmap updated with Phase 2 shipped.
- [ ] Commit messages cite the captured exception types + the fix rationale.
