# Indoor Cell Rendering Fix — Design

**Status:** Brainstormed 2026-05-19. Pivoted mid-brainstorm — see §1.5 for
the corrected root-cause analysis. Awaiting user review.
**Scope:** Diagnose + fix the actual break in the EnvCell rendering chain.
**Out of scope this phase:** Cell collision symptoms (no wall collision
exiting, weird open-air collisions). Filed as a follow-up phase pending
user repro data.

---

## 1. Symptom

Walking into Holtburg Inn: the exterior building stab renders (walls visible
from inside), but the interior cell's own room mesh — floor, inner walls,
ceiling — is missing. The user can walk through the empty interior with no
floor visible underfoot.

## 1.5 What the root cause is NOT (corrected mid-brainstorm)

Initial hypothesis: N.5 retirement (commit
[`dcae2b6`](../../../#) 2026-05-08) deleted the legacy cell-mesh drain path
with the assumption "WB handles EnvCell geometry through its own pipeline,"
and that assumption was wrong.

**Closer inspection during brainstorm proved that assumption is correct.**
WB's `ObjectMeshManager.PrepareMeshData(id, isSetup)` at
[`ObjectMeshManager.cs:557`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:557)
dispatches on the **dat record type** (not on the `isSetup` parameter).
When the id resolves to a `DBObjType.EnvCell`, it routes to
`PrepareEnvCellMeshData(id, envCell, ct)` at line 1186, which produces an
`ObjectMeshData` with `IsSetup=true`, `SetupParts` = [static objects +
cellGeometry], `EnvCellGeometry` = the floor/wall/ceiling room mesh.

The dispatcher correctly handles `IsSetup=true` at
[`WbDrawDispatcher.cs:607-621`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:607) —
it iterates `SetupParts`, looks up each part's render data, composes
transforms, and draws each.

`DefaultDatReaderWriter` loads region cell dats during construction
([`DefaultDatReaderWriter.cs:66-89`](../../../references/WorldBuilder/WorldBuilder.Shared/Services/DefaultDatReaderWriter.cs:66))
so `ResolveId(envCellId)` will find the cell record.

`LandblockSpawnAdapter.OnLandblockLoaded` iterates `landblock.Entities` and
calls `_adapter.IncrementRefCount(meshRef.GfxObjId)` for each
([`LandblockSpawnAdapter.cs:75-80`](../../../src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:75)).
Cell entities have `ServerGuid == 0` (atlas-tier), so they pass the filter
at line 73. Their `MeshRef.GfxObjId == envCellId` reaches `IncrementRefCount`.

**The chain looks structurally intact.** Floors SHOULD render today. They
don't. Therefore the failure is subtler than "we never invoke the load."

## 2. Real failure point — to be determined by diagnostics

Six untested hypotheses, in rough order of probability:

1. **WB silently fails to build the `ObjectMeshData`.** `PrepareEnvCellMeshData`
   returns null when the Environment dat can't resolve, or when
   `PrepareCellStructMeshData` returns null (texture issues, surface
   resolution failure). WB doesn't log; the failure is invisible.

2. **`SetupParts.cellGeomId` is uploaded but its texture batches are empty.**
   `UploadGfxObjMeshData` returning null at line 675 is treated as a
   non-fatal substitution — the render data has no draw batches, dispatcher
   silently draws nothing.

3. **Cell entity is culled before reaching the dispatcher.** `visibleCellIds`
   filter at `WbDrawDispatcher.cs:317-319` rejects entities whose
   `ParentCellId` isn't in the visible set. If the cell entity's
   `ParentCellId == envCellId` but the visibility BFS doesn't include the
   player's current cell (because `FindCameraCell` returns null when camera
   is in third-person above the building, etc.), the cell entity is
   skipped.

4. **Double-spawn conflict between WB's static-object SetupParts and
   acdream's per-stab entity hydration.** `PrepareEnvCellMeshData` iterates
   `envCell.StaticObjects` and adds each as a SetupPart. Meanwhile acdream
   already hydrates the same static objects as separate `WorldEntity`
   instances at [`GameWindow.cs:5390-5439`](../../../src/AcDream.App/Rendering/GameWindow.cs:5390).
   WB might be holding extra ref counts on those GfxObj IDs that block
   eviction or cause cache thrash. Unlikely to cause "missing floor" but
   worth ruling out.

5. **Transform composition bug.** `ComposePartWorldMatrix(entityWorld,
   meshRef.PartTransform, partTransform)` — if our cell entity's
   `meshRef.PartTransform == cellTransform` and WB's `partTransform`
   already bakes the cell origin, the floor lands at `2 × cellOrigin`,
   far below or beside the actual cell. The user would describe this
   as "missing" because the floor is now outside the visible frustum.

6. **The cell entity's `MeshRefs` only has one entry, but WB expects
   multiple.** The dispatcher iterates `entity.MeshRefs`, but each MeshRef
   gets its own `TryGetRenderData(meshRef.GfxObjId)` call. For cell
   entities we have `MeshRefs = { MeshRef(envCellId, cellTransform) }`.
   When the lookup returns an `IsSetup=true` render data, the dispatcher
   does the right thing (line 607-621) — iterates SetupParts. So this
   should work; ruling out.

## 3. Solution

### Phase 1 — Diagnostics (this phase's work)

Five probes, each individually toggleable via env-var + DebugPanel
checkbox. The probes live in a new
`AcDream.Core.Rendering.RenderingDiagnostics` static class (mirroring
the `AcDream.Core.Physics.PhysicsDiagnostics` pattern shipped in L.2a)
so they're discoverable from one place and survive across the
Core / App seam.

Each probe is **rate-limited**: by default, one line per (envCellId,
frame-modulo-30) — i.e., once per second per cell at 30 Hz — to avoid
log spam. When `ACDREAM_PROBE_INDOOR_VERBOSE=1` is also set, the
rate-limit drops and every frame logs.

| Env var (and DebugPanel mirror) | Probe | Code location | Line format |
|---|---|---|---|
| `ACDREAM_PROBE_INDOOR_WALK` | Cell-entity dispatcher walk | `WbDrawDispatcher.WalkVisibleEntities` (rate-limited per cellId) | `[indoor-walk] cellEnt=0xID pos=(x,y,z) parentCell=0xID landblockVisible=B aabbVisible=B cellInVis=B drawn=B` |
| `ACDREAM_PROBE_INDOOR_LOOKUP` | Render-data lookup for cell entities | `WbDrawDispatcher.DrawAccumulated` per cell entity | `[indoor-lookup] cellId=0xID hit=B isSetup=B partCount=N hasEnvCellGeom=B partsHit=N partsMiss=N` |
| `ACDREAM_PROBE_INDOOR_UPLOAD` | WB upload result for envCellId | `WbMeshAdapter.IncrementRefCount` (on first call per id) + a callback hooked into `_meshManager.Tick()` for completion | `[indoor-upload] cellId=0xID requested=true completed=B partsCount=N cellGeomVerts=N error="..."` |
| `ACDREAM_PROBE_INDOOR_XFORM` | Composed world transform for cell-geometry SetupPart | `WbDrawDispatcher` inside the `IsSetup` branch at line 607-621, for partGfxObjId matching `(envCellId | 0x1_00000000UL)` | `[indoor-xform] cellId=0xID cellOrigin=(x,y,z) entityWorld=(...) partTransform=(...) composed=(x,y,z y-axis,z-axis) detExpected≈1 detActual=F` |
| `ACDREAM_PROBE_INDOOR_CULL` | Visibility / cull decision per cell entity | `WbDrawDispatcher.WalkVisibleEntities` (the two filter sites at lines 304-305 and 317-319) | `[indoor-cull] cellEnt=0xID reason="visibleCellIds-miss" or "frustum" or "served" details="..."` |

The five probes can be enabled independently or together. The user's
common case is `ACDREAM_PROBE_INDOOR_ALL=1` which sets all five at
once.

#### Implementation outline

1. **New file** `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` —
   five static `bool` properties, each backed by an env-var read at
   startup, each runtime-settable from the DebugPanel.
2. **DebugPanel section** — new "Indoor rendering diagnostics" block
   in the existing DebugPanel "Diagnostics" group, with one checkbox
   per probe + a master "all" toggle.
3. **WbDrawDispatcher edits** — instrument the walk and the IsSetup
   draw branch. The walk probe needs to know whether the entity passed
   the cell-visibility filter; the cull probe needs the same data.
   Cleanest: emit BOTH lines in one place when either probe is on.
4. **WbMeshAdapter edits** — `IncrementRefCount` logs an `[indoor-upload]
   requested=true` line when the id is recognized as an EnvCell
   (high-bit check `(id & 0xFFFF) >= 0x0100`). On Tick(), when a
   completion drains for an envCellId, log the result line with the
   actual ObjectMeshData/ObjectRenderData fields.
5. **No GameWindow changes** beyond passing the diagnostics class
   into the dispatcher (if not already accessible).

#### Capture procedure

1. Build with the probe instrumentation. `dotnet build` green.
2. Launch with `ACDREAM_PROBE_INDOOR_ALL=1`. Walk to Holtburg Inn,
   stand at the doorway, then step inside, then walk around the room.
3. Stop the client, grep `launch.log` for `[indoor-*]` lines.
4. The captured log identifies WHICH hypothesis matches:
   - **H1 (null upload)** → `[indoor-upload] completed=false`
   - **H2 (empty batches)** → `[indoor-upload] cellGeomVerts=0`
   - **H3 (cull bug)** → `[indoor-cull] reason="visibleCellIds-miss"`
   - **H4 (double-spawn)** → `[indoor-lookup] partCount` includes
     static-object IDs that ALSO appear in `landblock.Entities`
   - **H5 (transform double-apply)** → `[indoor-xform] composed`
     world position lands at `2 × cellOrigin` instead of `cellOrigin`
   - **H6 (MeshRefs structure)** → ruled out; probe data would still
     surface it as `hit=true isSetup=true partCount=N` followed by
     all `partsHit=0`

### Phase 2 — Fix the specific break (next phase)

Once the probe identifies the failure point, implement the surgical
fix. Likely shapes per hypothesis:

| Hypothesis | Fix shape |
|---|---|
| H1 — WB returns null | Add WB logging or pre-check the dat resolution path in WbMeshAdapter |
| H2 — Empty batches | Investigate WB texture pipeline; possibly a missing texture in the cell's surface list |
| H3 — Cull bug | Fix `ParentCellId` assignment OR loosen the visibility filter for cell entities |
| H4 — Double-spawn | Stop WB from spawning static-object parts in EnvCell setups (filter them in PrepareEnvCellMeshData, or skip acdream's per-stab hydration when WB handles the cell) |
| H5 — Transform double-apply | Replace `MeshRef.PartTransform = cellTransform` with `entity.Position+Rotation = cellPosition` |
| H6 — MeshRefs structure | Already ruled out in §2 |

Phase 2's actual code change is small and well-targeted once Phase 1
gives us a definite answer.

## 4. Why NOT build a separate cell renderer

The original brainstorm proposed adapting `_pendingCellMeshes` data into
WB via a new `UploadCellMesh` adapter method. **That solution is wrong** —
it would duplicate work WB already does, fragment the rendering pipeline,
and bypass WB's existing GPU memory management. Worse, it would hide
whatever the actual bug is, not fix it.

## 5. Edge cases

| Scenario | Behavior |
|---|---|
| Visible during diagnostic capture | Probe is heavy (per-frame, per-entity). Bounded by short walk; runtime-toggle off when done. |
| Probe spam in production | Default OFF, mirrored to DebugPanel. Same pattern as L.2a `ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL`. |
| Concurrent landblock stream | Probe records per frame across all loaded cells — useful for cross-cell comparison ("does cell X load but cell Y not?"). |

## 6. Testing strategy

**Unit tests:** none in Phase 1. The probe is diagnostic, not behavioral.

**Visual verification (user-driven, end-to-end):**

- Add probe, launch client, walk into Holtburg Inn.
- Read probe output to identify which hypothesis matches.
- Brief Phase 2 in a new design (or amend this one) once the failure
  point is known.

**Phase 2 unit tests:** depend on the fix shape. If H5 (transform
double-apply), tests verify the world matrix composition. If H3 (cull
bug), tests verify visibility BFS for indoor entities.

## 7. What's NOT in this phase

- Cell collision symptoms — investigated separately.
- Particle/fire emitter integration — already shipped.
- Light registration — already shipped.
- Stab-leak-through-walls — deferred.

## 8. Acceptance criteria

**Phase 1 (this phase):**

- [ ] `AcDream.Core.Rendering.RenderingDiagnostics` static class created
      with five `bool` properties + master `IndoorAll` toggle, each backed
      by an env-var read at startup and runtime-settable.
- [ ] DebugPanel "Diagnostics" group has a new "Indoor rendering"
      subsection with six checkboxes (five probes + master).
- [ ] `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`,
      `[indoor-xform]`, `[indoor-cull]` lines when the respective probe
      is on. Rate-limited to ~1/sec per cell unless verbose mode active.
- [ ] `WbMeshAdapter` emits `[indoor-upload]` lines for EnvCell IDs:
      one `requested` line on first `IncrementRefCount`, one `completed`
      line when WB's Tick drains the result (success or failure).
- [ ] `dotnet build` clean. `dotnet test` clean (the diagnostics-only
      change should not affect any test).
- [ ] Probe captured at Holtburg Inn confirms which hypothesis matches.
      Capture procedure documented in §3 above.
- [ ] Phase 2 design (amended spec or new spec) documents the surgical
      fix matched to the identified hypothesis.

**Phase 2 (next phase, driven by Phase 1 output):**

- [ ] `dotnet build` clean, `dotnet test` clean.
- [ ] Visual verification: walking into Holtburg Inn renders interior
      floor + walls correctly.
- [ ] Roadmap updated.
- [ ] Probes left in place for future regressions but defaulted off.
