Merge branch 'claude/flamboyant-williams-ef9a41' — indoor cell rendering Phase 1 + Phase 2

Phase 1 (diagnostics) + Phase 2 (fix) for the missing indoor floors.

Root cause: WB's PrepareEnvCellMeshData at ObjectMeshManager.cs:1223
blindly called TryGet<Setup> on every EnvCell.StaticObjects entry,
including GfxObj-prefixed (0x01xxxxxx) ids. DatReaderWriter tried to
parse GfxObj bytes as Setup format, threw ArgumentOutOfRangeException,
which WB's outer try/catch silently swallowed. 26/123 Holtburg cells
failed upload — their floor / wall / ceiling geometry never rendered.

Fix shape: pre-check Setup-prefix bit before calling TryGet<Setup>.
Committed to the WB submodule on the acdream branch
(eriknihlen/WorldBuilder@34460c4); submodule pointer advanced here.

Diagnostic infrastructure (5 [indoor-*] probes + ContinueWith exception
surfacer + ConsoleErrorLogger injection into WB) shipped in
AcDream.Core.Rendering.RenderingDiagnostics + WbMeshAdapter +
WbDrawDispatcher + DebugPanel — left in place for future indoor
debugging.

Other fixes:
- Indoor ambient: hardcoded (0.10, 0.09, 0.08) → retail's (0.20, 0.20, 0.20)
  per CellManager::ChangePosition @ 0x004559B0.
- Indoor lighting trigger: switched from cameraInsideCell to
  playerInsideCell (third-person chase camera enters interiors first;
  retail keys lighting off player position).

Surfaced 9 pre-existing indoor bugs filed in docs/ISSUES.md (#78-#86):
see-through floor (#78), spurious spot lights (#79), 2nd-floor darkness
(#80), atmospheric lighting on stabs (#81), slope terrain lighting
(#82), broken stairs (#83), blocked by air (#84), pass-through walls
(#85), click-through-walls (#86). Probable shared root causes group
into cell-BSP cluster + indoor-lighting plumbing cluster.

Visual-verified at Holtburg Inn — floors render. 0 [wb-error] (was 385).
0 [indoor-upload] NULL_RESULT (was 55).

Specs: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
       docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md
Plans: docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md
       docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md
Research: docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
          docs/research/2026-05-19-indoor-cell-rendering-cause.md
          docs/research/2026-05-19-indoor-cell-rendering-verification.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 13:32:17 +02:00
commit 3bf30d2c2b
19 changed files with 3170 additions and 19 deletions

View file

@ -46,7 +46,281 @@ Copy this block when adding a new issue:
# Active issues # Active issues
## #76 — [DONE 2026-05-16 · `0b25df5`] LiveSessionController extraction (Step 2) regresses interaction + chat outbound ---
## Indoor walking issue cluster (2026-05-19)
The Phase 2 indoor cell rendering fix (floor now renders inside buildings)
surfaced nine pre-existing indoor bugs the user observed at Holtburg Inn
the moment they could walk indoors. None caused by the floor fix — all
existed before but were unobservable because there was no floor to stand
on. Filed individually below; #78 + #84 + #85 + #86 likely share a root
cause (cell BSP / portal-cull plumbing), and #79 + #80 + #81 + #82 share
the indoor-lighting plumbing.
---
## #78 — Outdoor stabs/buildings visible through the rendered floor
**Status:** OPEN
**Severity:** HIGH (immediate visual jank now that floors render)
**Filed:** 2026-05-19
**Component:** rendering, visibility
**Description:** Standing inside Holtburg Inn looking at the floor or
walls, the user sees other buildings in the distance at their correct
world position + scale — but visible THROUGH the floor and walls. As if
the cell mesh is rendered but doesn't occlude or stencil-cull what's
behind it.
**Root cause / status:** Two plausible causes:
1. The `+0.02f` Z bump applied to cell origin at `GameWindow.cs:5362`
pushes the floor mesh 2 cm above terrain, so depth test correctly
occludes terrain. But OUTDOOR STABS (landblock-baked building geometry)
at the same X,Y may have Z values comparable to or higher than the
cell-mesh floor, producing z-fighting / see-through.
2. Outdoor stabs aren't being culled when the player is inside an
EnvCell — this is the Phase 1 Task 3 deferred work
("Cull outdoor stabs when indoors via VisibleCellIds"). WB has a
`RenderInsideOut` stencil pipeline (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`)
that acdream never invokes.
**Files:**
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (per-entity walk —
consider gating outdoor stab entities on visible-cell membership).
- `src/AcDream.App/Rendering/CellVisibility.cs:222+` (`ComputeVisibility`
returns `VisibleCellIds`; the dispatcher already filters by
`entity.ParentCellId ∈ visibleCellIds` but outdoor stabs have
`ParentCellId == null` so they always pass).
**Acceptance:** Standing inside a sealed-interior cell, no outdoor
geometry is visible through floor/walls. Standing where a cell has a
real outdoor portal (door open, window) outdoor geometry is correctly
visible through the portal.
---
## #79 — Indoor lighting: spurious spot lights on walls
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** lighting
**Description:** Walking around inside Holtburg Inn, the user sometimes
sees spot-light-like patches on the interior walls that don't correspond
to retail's lighting.
**Root cause / status:** Point lights from cell static objects (torch
entities) are being registered via `LightInfoLoader.Load` + `LightingHookSink`
(Phase 1 verified). Their per-light parameters (position, range, intensity,
cone) may be wrong — wrong falloff treatment, wrong world-space transform,
or wrong direction for spot lights. Spec at
`docs/research/deepdives/r13-dynamic-lighting.md` documents the retail
LightInfo→LightSource mapping but the live behavior hasn't been verified
against retail.
**Files:**
- `src/AcDream.Core/Lighting/LightInfoLoader.cs`
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag``accumulateLights`
spot-cone logic.
**Acceptance:** Side-by-side comparison with retail at the inn shows
matching torch-light pools.
---
## #80 — Camera on 2nd floor goes very dark
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** lighting
**Description:** Walking up to the second floor of a building, the
lighting suddenly goes much darker than retail.
**Root cause / status:** Possible causes:
1. The `playerInsideCell` lighting trigger (Phase 1 / commit `1024ba3`)
uses `CellVisibility.IsInsideAnyCell(playerPos)` which is a brute-force
PointInCell scan. The 2nd floor cell may not be in the loaded set OR
may have wrong bounds.
2. The per-cell ambient is currently a flat `(0.20, 0.20, 0.20)` for
any indoor cell. Retail has per-cell ambient overrides; ours doesn't
read them. A 2nd-floor cell with stairwell shadowing may need a
different value.
**Files:**
- `src/AcDream.App/Rendering/GameWindow.cs:8330+` (`UpdateSunFromSky`,
indoor branch).
**Acceptance:** 2nd-floor cells render with similar brightness to
ground floor; transition is not abrupt.
---
## #81 — Static building stabs don't react to atmospheric lighting changes
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** lighting, rendering
**Description:** Outside, time-of-day changes (sunrise/sunset/lightning)
don't visibly affect static building stabs (the inn / cottages). The
buildings stay statically lit while terrain and scenery shift colors.
**Root cause / status:** Stabs are rendered through `WbDrawDispatcher`
with `mesh_modern.frag` which DOES consume the `SceneLightingUbo`
(sun + ambient + fog). Verify the shader is being used for stabs and
that the UBO is bound at the right binding slot per draw call.
Possibly a shader-path divergence — terrain uses `terrain_modern.frag`,
entities use `mesh_modern.frag`, but stabs/scenery may be on a
different path.
**Files:**
- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
**Acceptance:** Stabs darken/brighten in sync with terrain + scenery
across the day/night cycle.
---
## #82 — Some slope terrain lit incorrectly
**Status:** OPEN
**Severity:** LOW (cosmetic)
**Filed:** 2026-05-19
**Component:** rendering, terrain
**Description:** Specific terrain slopes appear lit "wrong" compared to
retail.
**Root cause / status:** Likely terrain normal calculation or the
landblock-edge normal-blending divergence between WB and retail (per
`feedback_wb_migration_formulas.md` — WB's terrain split formula
differs from retail's `FSplitNESW`).
**Files:**
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
**Acceptance:** Side-by-side comparison with retail at the same Holtburg
slopes shows matching shading.
---
## #83 — Walking up stairs broken
**Status:** OPEN
**Severity:** HIGH (blocks vertical indoor traversal)
**Filed:** 2026-05-19
**Component:** physics, movement
**Description:** When the player tries to walk up stairs inside a
building, movement is broken — gets stuck, gets bounced, or fails to
ascend.
**Root cause / status:** The retail physics has explicit step-up logic
(`CPhysicsObj::step_up` etc.) ported into `PhysicsEngine` for outdoor
terrain ramps. For indoor stairs (EnvCell CellStruct geometry composed
of polygons), the step-up resolver may not be examining cell BSP
correctly, OR cell BSP and cell mesh disagree on stair Z values.
**Files:**
- `src/AcDream.Core/Physics/PhysicsEngine.cs`
- `src/AcDream.Core/Physics/TransitionTypes.cs` (cell BSP query path).
**Acceptance:** Walking forward at the base of an inn stairwell ascends
to the second floor without getting stuck.
---
## #84 — Blocked by air indoors
**Status:** OPEN
**Severity:** HIGH (blocks indoor navigation)
**Filed:** 2026-05-19
**Component:** physics, collision
**Description:** While walking inside buildings, the player sometimes
collides with invisible obstacles in mid-floor where there's nothing
visible.
**Root cause / status:** Cell BSP geometry doesn't align with the
visible cell mesh. Possibilities:
1. The `cellTransform` applied to physics in
`_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)`
at `GameWindow.cs:5384` includes the `+0.02f` Z bump, but the BSP
geometry may not be lifted with it — physics geometry sits 2cm BELOW
render geometry, so invisible "ceilings" at floor-level cause
blockage.
2. CellStruct BSP contains polygons that the cell mesh doesn't include
(or vice versa) — the two are derived from different fields.
**Files:**
- `src/AcDream.App/Rendering/GameWindow.cs:5362-5384` (cellOrigin Z bump
+ physics cache call).
**Acceptance:** Walking through interior cell space hits collisions
only where visible walls/furniture exist.
---
## #85 — Pass through walls from outside→in
**Status:** OPEN
**Severity:** HIGH (gameplay-breaking)
**Filed:** 2026-05-19
**Component:** physics, collision
**Description:** Approaching a building from the outside, the player
can walk THROUGH walls into the interior — one-directional wall
collision. From the inside trying to exit, the wall does block.
**Root cause / status:** Cell BSP polygons likely have one-sided
normals (front-facing only). Approach from the inside hits the front;
approach from the outside hits the back which BSP traversal treats as
"behind the plane" → no collision. Retail handles this via two-sided
collision polys or per-poly back-face handling.
**Files:**
- `src/AcDream.Core/Physics/BSPQuery.cs`
- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` cell
branch).
**Acceptance:** Walking into an inn wall from outside collides; player
must enter via the door portal.
---
## #86 — Click selection penetrates walls
**Status:** OPEN
**Severity:** MEDIUM
**Filed:** 2026-05-19
**Component:** input, interaction
**Description:** Clicking through a wall from the outside selects NPCs
and objects inside the building. The `WorldPicker` raycast doesn't
intersect cell BSP geometry.
**Root cause / status:** `WorldPicker.BuildRay + Pick` (introduced in
Phase B.4) tests against entity AABBs and scenery BSPs but probably
not cell BSP. Outdoor NPCs are pickable because their entity AABB is
the test target; indoor NPCs are pickable from outside because the
wall isn't in the ray's intersection set.
**Files:**
- `src/AcDream.App/Rendering/WorldPicker.cs` (or equivalent — check
Phase B.4b reference).
**Acceptance:** Clicking on a wall doesn't select NPCs behind it.
---
**Status:** DONE **Status:** DONE
**Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged) **Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged)

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap # acdream — strategic roadmap
**Status:** Living document. Updated 2026-05-12. **Between phases.** **Since the last header update:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). **Earlier this week:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 3050×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b). **Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** Indoor cell rendering Phase 1 (diagnostics) + Phase 2 (fix) shipped — root cause was a one-line WB bug at `ObjectMeshManager.cs:1223` (blind `TryGet<Setup>` on GfxObj-prefixed stab ids threw `ArgumentOutOfRangeException` which WB's outer catch silently swallowed, causing 26/123 Holtburg cells to fail upload). Identified via diagnostic chain (5 `[indoor-*]` probes + a `ContinueWith` exception surfacer + a `ConsoleErrorLogger` injected into WB), fixed with a Setup-prefix guard. User visually confirmed floors render. Surfaced 9 pre-existing indoor bugs filed in `docs/ISSUES.md`. **Earlier:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 3050×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
--- ---
@ -68,6 +68,8 @@
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ | | B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ | | B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ | | B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ | | C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
Plus polish that doesn't get its own phase number: Plus polish that doesn't get its own phase number:

View file

@ -0,0 +1,94 @@
# Indoor Cell Rendering — Phase 2 Cause Report
**Date:** 2026-05-19
**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
**Capture method:** Phase 2's `ContinueWith` + `ConsoleErrorLogger` injected into WB's `ObjectMeshManager` surfaced the exception WB was silently catching.
## Cause
**Single failure mode:** `ArgumentOutOfRangeException` thrown from `DatReaderWriter.DBObjs.Setup.Unpack` at WB's [`ObjectMeshManager.cs:1223`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1223):
```csharp
// For EnvCell static objects, we need to manually collect emitters if they are Setups
if (_dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) { // ← throws
```
WB iterates `envCell.StaticObjects` and **blindly calls `TryGet<Setup>` on every stab id**, regardless of whether the id is actually a Setup-prefix (`0x02xxxxxx`) or a GfxObj-prefix (`0x01xxxxxx`). When stab.Id is a GfxObj, `DatReaderWriter` finds the file (Portal dat has both GfxObjs and Setups under the same tree-lookup) and attempts to deserialize the GfxObj bytes as a Setup record. The Setup format is structurally different — early parse fails inside `QualifiedDataId.Unpack``DatBinReader.ReadBytesInternal` throws `ArgumentOutOfRangeException`.
The exception bubbles up to `PrepareMeshData`'s outer try/catch at line 589:
```csharp
catch (Exception ex) {
_logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
return null; // ← swallows exception, returns null
}
```
The entire EnvCell upload fails silently. The cell's room geometry (floor / walls / ceiling) never reaches `_renderData`, so the dispatcher skips drawing it. Static objects inside the cell (which acdream hydrates separately) still render — they have their own GfxObj uploads.
**This also explains the user's "objects below ground" observation:** with the floor mesh missing, you see the cell's static objects (tables / chairs / fireplaces) through where the floor should be. Visually they appear "below ground."
## Sample evidence
55 NULL_RESULT cells captured at multiple landblocks (`0xA5B4`, `0xA7B4`, `0xA8B2`, `0xA9B0`, `0xA9B2`, `0xA9B3`, `0xA9B4`). All 55 share the same exception type and stack frame:
```
[wb-error] Error preparing mesh data for 0x00000000A9B20114
[wb-error] ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
[wb-error] at DatReaderWriter.DBObjs.Setup.Unpack(DatBinReader reader)
[wb-error] at DatReaderWriter.DatDatabase.TryGet[T](UInt32 fileId, T& value)
[wb-error] at WorldBuilder.Shared.Services.DefaultDatDatabase.TryGet[T](UInt32 fileId, T& value)
[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareEnvCellMeshData(...) line 1223
[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareMeshData(...) line 571
```
For Holtburg (`0xA9B4`) specifically: 123 requested → 97 completed + 26 silently failed. The 26 failures all match this exception signature. The first interior cell `0xA9B40100` is among them — exactly where the user reported a missing floor.
## Why the other hypotheses were ruled out
Phase 1 ruled out H2-H6 via the captured probe data. Phase 2's diagnostic walk:
1. `ourCellDb.TryGet=True` — acdream's DatCollection finds the cell.
2. `wbResolveId.Count=1` — WB's ResolveId also finds it.
3. `wbSelectedType=EnvCell` — type classification is correct.
4. `wbDbTryGet<EnvCell>=True` — the cell record IS loadable by WB.
5. `hadRenderData=False` at request time — no pre-existing cache hit.
All preconditions for a successful upload were met. The failure was in a downstream emitter-collection step (line 1223) that's tangential to the cell's own geometry — but its exception silently kills the entire upload.
## Fix
**One-line WB fork patch.** Pre-check the Setup-prefix bit before calling `TryGet<Setup>`:
```csharp
// Before:
if (_dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) {
// After:
if ((stab.Id & 0xFF000000u) == 0x02000000u
&& _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)) {
```
For GfxObj-prefixed stabs (which have no `DefaultScript` and no emitters anyway), the branch is now skipped correctly. For Setup-prefixed stabs, behavior is unchanged.
This is in our WB fork at [`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230). The patch should be upstreamed — it's a real WB bug.
## Verification approach
After applying the fix:
1. Re-launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`.
2. Walk Holtburg.
3. Expect: zero `[wb-error]` lines, zero `[indoor-upload] NULL_RESULT` lines. Previously-failing cells now have `[indoor-upload] completed` lines.
4. Visual: floor renders in Holtburg Inn; objects no longer appear "below ground."
## Phase 1 → Phase 2 chain summary
The diagnostic-driven approach worked end-to-end:
- **Phase 1:** Added 5 probes. Identified that 26 Holtburg cells silently fail. Confirmed H1 class of bug. Could not pinpoint without exception data.
- **Phase 2 Task 1:** Wrapped `PrepareMeshDataAsync` in a continuation to capture `Task.Exception`. Found that the task was never faulted — `tcs.TrySetResult(null)` ran instead. Hypothesized exception was swallowed inside `PrepareMeshData`.
- **Phase 2 cause-narrowing diagnostics:** Added `ourCellDb.TryGet` + `wbResolveId.Count` + `wbSelectedType` + `wbDbIsPortal` + `wbDbTryGet<EnvCell>` + `hadRenderData` checks. Each iteration narrowed the cause class.
- **Phase 2 final probe:** Replaced WB's `NullLogger` with a Console-backed `ConsoleErrorLogger`. WB's existing `_logger.LogError(ex, ...)` call at the catch block immediately surfaced 55 ArgumentOutOfRangeException stack traces with file:line locations. **Cause definitively identified in one capture.**
- **Phase 2 fix:** One-line guard at the throwing call site.
Total runtime: ~3 client launches to nail it.

View file

@ -0,0 +1,105 @@
# Indoor Cell Rendering — Phase 1 Probe Capture
**Date:** 2026-05-19
**Probe:** Phase 1 diagnostic probes from spec `2026-05-19-indoor-cell-rendering-fix-design.md`
**Capture conditions:** `ACDREAM_PROBE_INDOOR_ALL=1`, walk into Holtburg (landblock `0xA9B4`).
**Verdict:** Hypothesis **H1 (WB silently returns null from `PrepareEnvCellMeshData`)** is **CONFIRMED** for ~21% of Holtburg's EnvCells, including the first interior cell `0xA9B40100`.
---
## Probe line breakdown (real EnvCell-format IDs only)
| Probe | Count | Notes |
|---|---|---|
| `[indoor-upload] requested` (0xA9B4 cells) | 123 (unique) | LandblockSpawnAdapter triggers PrepareMeshDataAsync for every cell in Holtburg landblock. |
| `[indoor-upload] completed` (0xA9B4 cells) | **97** (unique) | **26 cells never produce a completed line.** |
| `[indoor-walk]` (cell-room entities, 0xA9B4) | 27,631 | Cell-room entities pass `landblockVisible` + `aabbVisible` + `cellInVis` filters. Walk path is healthy. |
| `[indoor-lookup]` (0xA9B4 cells) | 6,067 | Total dispatcher lookups for Holtburg cells. |
| `[indoor-lookup] hit=True` | 45 | Only ~0.7% hit rate — the rate-limited probe captures one snapshot per cell after rendering stabilizes. |
| `[indoor-lookup] hit=False` | 6,022 | Most are pre-upload-completion frames + the 26 silently-failing cells. |
| `[indoor-xform]` | 97 | One per successfully-uploaded cell. Cell-geom SetupPart's render data is non-null and reaches `ComposePartWorldMatrix`. |
## Hypotheses
### H1 — WB silently returns null from `PrepareEnvCellMeshData` ✅ CONFIRMED
26 out of 123 Holtburg cells (21%) get an `[indoor-upload] requested` line but **never** produce an `[indoor-upload] completed` line. This is the classic H1 signature: WB's `ObjectMeshManager.PrepareMeshData` either returns null (line 568, 583, 592 of `ObjectMeshManager.cs`) or its catch-block swallows an exception at line 589-592. The pending `meshData` never reaches `StagedMeshData`, so `Tick()`'s drain never sees it, no completion line emits.
**First 15 cells with no completion:**
```
0xA9B40100, 0xA9B40111, 0xA9B40112, 0xA9B40117, 0xA9B4011B,
0xA9B40121, 0xA9B40123, 0xA9B40129, 0xA9B4012A, 0xA9B4012E,
0xA9B40138, 0xA9B4013F, 0xA9B40141, 0xA9B40143, 0xA9B40147
```
`0xA9B40100` is **the first indoor cell** in Holtburg landblock. Almost certainly the inn entry or another major building's anchor cell — exactly where the user reported "floor missing."
### H2 — Empty batches ❌ RULED OUT
For successfully-completed cells, `cellGeomVerts` ranges 1486 and `hasEnvCellGeom=True`. Geometry is non-empty when the upload completes. The 26 failing cells fail BEFORE batch construction, so this isn't an empty-batch problem.
### H3 — Cull bug ❌ RULED OUT
`[indoor-cull]` lines for cell-room entities show `visibleCellIds-miss` reasons only for cells in *other* landblocks (`0xA9B0`, `0xA9B2`, `0xA9B3` etc., visible neighbours of Holtburg but outside the active visibility set). For Holtburg's own cells, the walk probe shows `landblockVisible=true aabbVisible=true cellInVis=true` consistently — the dispatcher reaches them.
### H4 — Double-spawn ❌ RULED OUT
For completed cells, `[indoor-lookup]` reports modest `partCount` values (146) matching the number of static objects + 1 cell-geom part. No evidence of duplicate registration.
### H5 — Transform double-apply ❌ RULED OUT
`[indoor-xform]` consistently shows `entityWorldT=(0,0,0)`, `partT=(0,0,0)`, and `composedT==meshRefT`. The composed translation equals the cell's world origin — no double-apply. Sample:
```
[indoor-xform] cellGeomId=0x00000001A9B40101
entityWorldT=(0.00,0.00,0.00)
meshRefT=(84.09,131.54,66.02)
partT=(0.00,0.00,0.00)
composedT=(84.09,131.54,66.02)
```
### H6 — MeshRefs structure mismatch ❌ RULED OUT
For uploaded cells, `[indoor-lookup]` shows `hit=True isSetup=True partsHit≈partCount`. The dispatcher correctly traverses the Setup parts. Sample: `[indoor-lookup] cellId=0xA9B40101 hit=True isSetup=True partCount=10 hasEnvCellGeom=True partsHit=9 partsMiss=1`.
---
## What's special about the 26 failing cells?
Unknown from Phase 1 probes alone. Possible causes (each verifiable with one or two more targeted probes or code reads in Phase 2):
1. **Missing Environment dat record**`envCell.EnvironmentId` points at an Environment id that `_dats.Portal.TryGet<Environment>` can't find. WB's `PrepareEnvCellMeshData` line 1245 would silently return without populating `cellGeometry`, then the outer Setup path produces a result with `hasBounds=false` and an empty `parts` list. Hmm, but that would still produce a `completed` line — just with empty data. **So this would be H2-shaped, not H1-shaped.** Ruled out.
2. **Exception in `PrepareCellStructMeshData`** — texture decode failure, surface ID resolution failure, polygon enumeration crash. The catch-block at `PrepareMeshData` line 589 silently swallows. **Most likely cause.**
3. **`ResolveId(envCellId)` returns empty** — WB's `DefaultDatReaderWriter` can't find the cell record in its loaded dats. Unlikely (all region cells are loaded at construction), but possible if `_wbDats.Portal.TryGet<Region>` skipped the region containing 0xA9B4.
4. **Race condition**`PrepareMeshData` runs on a background worker; if the same cell id is requested twice in fast succession before the first completes, the second `TryAdd` to `_preparationTasks` returns false and silently skips. Unlikely given LandblockSpawnAdapter's per-landblock dedup at line 68 of `LandblockSpawnAdapter.cs`, but possible if multiple landblocks share state.
---
## Phase 2 — recommended approach
The fix shape per the spec table maps H1 to: *"Add WB logging or pre-check the dat resolution path in WbMeshAdapter."*
Concrete Phase 2 plan:
1. **Targeted probe extension** — add a SECOND probe inside the failing path. Either patch WB to surface the swallowed exception (`PrepareMeshData` line 589 catch block) OR wrap the `PrepareMeshDataAsync` call in WbMeshAdapter with our own try/catch + task continuation that logs the actual `Exception` for EnvCell ids. One launch with this captures the actual failure reason for the 26 cells.
2. **Match the failure to a fix** — once we know the failure mode:
- If a texture/surface bug → file as a Phase 2 WB-fork patch.
- If a missing dat reference → check whether the user's `client_cell_1.dat` is up to date.
- If an exception in our code path → fix the specific bug.
3. **Verify** by re-launching with the probe and confirming `[indoor-upload] completed` appears for previously-missing cells (e.g., `0xA9B40100`).
---
## Phase 1 leftover observations
- The `IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u` helper has false positives on GfxObj IDs whose lower 24 bits happen to be ≥ 0x0100 (e.g., `0x01001841`). This polluted ~95% of probe emissions with non-cell entities. Recommend tightening the helper to also require `(id >> 24) != 0x01 && (id >> 24) != 0x02` (and any other DBObj-type prefixes), OR `(id >> 16) > 0x00FF` to require a real landblock prefix.
- The lookup probe's rate-limit namespace separation (Task 7 fix) works correctly — uploaded cells DO appear in the hit set when their lookup probe fires.
- Cell-room entities have `Position=(0,0,0)` with the cell transform in `MeshRef.PartTransform`. The dispatcher's `aabbVisible` filter passed for them, presumably because `RefreshAabb()` computes a sensible world AABB from the mesh-ref's transform or because the landblock equals `neverCullLandblockId`. Worth a brief audit if there's any reason to believe the cell-room AABB is wrong.

View file

@ -0,0 +1,62 @@
# Indoor Cell Rendering — Phase 2 Verification
**Date:** 2026-05-19
**Outcome:** ✅ Floor renders in Holtburg Inn. User visually confirmed.
**Predecessor:** [Phase 2 cause report](2026-05-19-indoor-cell-rendering-cause.md).
---
## Probe re-capture
After applying the one-line WB fix at [`ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230):
| Metric | Pre-fix | Post-fix |
|---|---|---|
| `[wb-error]` lines | 385 | **0** |
| `[indoor-upload] NULL_RESULT` | 55 | **0** |
| `[indoor-upload] FAILED` | 0 | 0 |
| Total `[indoor-upload] requested` | — | 1157 |
| Total `[indoor-upload] completed` | — | **1157** |
| Holtburg (`0xA9B4`) requested | 123 | 123 |
| Holtburg (`0xA9B4`) completed | 97 | **123** |
| Holtburg (`0xA9B4`) missing | 26 | **0** |
100% success rate on EnvCell uploads. Zero swallowed exceptions. Zero null returns.
## Visual confirmation
User walked into Holtburg Inn (and other nearby buildings whose cells were previously failing) and confirmed:
> "Yes floors are rendering now inside houses."
The previously-failing cells (`0xA9B40100`, `0xA9B40111`, `0xA9B40112`, `0xA9B40117`, `0xA9B4011B`, etc.) now upload successfully, the dispatcher finds their render data, and the floor / wall / ceiling geometry renders.
## Regressions checked
- Outdoor terrain still renders correctly. ✓
- Outdoor scenery (trees, rocks, stabs) still render. ✓
- NPCs, mobs, world entities still render. ✓
- Build clean, no new warnings. ✓
- No new test failures. ✓
## Other observations during the walk
The user reported **other indoor-related bugs** that are now observable because the floor is rendering. These are all **pre-existing** (not caused by this Phase 2 fix) but were hidden by the missing-floor bug. They are filed as separate issues for follow-up phases:
1. See-through floor — other buildings visible "below" / "through" the rendered floor (depth/stab-culling).
2. Spot lights on walls indoors (point-light positioning).
3. Camera on 2nd floor goes very dark (per-cell ambient or trigger).
4. Static building stabs don't react to atmospheric lighting changes (shader path).
5. Some slope terrain lit incorrectly (terrain normal calculation).
6. Collision "blocked by air" indoors (cell BSP misalignment).
7. Walking up stairs broken (stair-step physics on EnvCell geometry).
8. Pass through walls from outside→in (one-sided wall collision).
9. Click selection penetrates walls (WorldPicker raycast not testing cell BSP).
These nine items are tracked in `docs/ISSUES.md` with proposed phase groupings. None block Phase 2 closure.
## Conclusion
**Phase 2 of the indoor cell rendering fix is complete.** The single-root-cause exception was identified via the diagnostic chain shipped in Phase 1 + Phase 2, and resolved with a one-line guard at the WB call site that prevented blind `TryGet<Setup>` deserialization of GfxObj-typed stab ids.
Total runtime for Phase 2: ~4 client launches.

View file

@ -46,7 +46,7 @@ public partial class LightInfo : IDatObjType {
- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive. - **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors. - **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) — then add active lights on top. See §12 for the C# class. - **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or **a flat `(0.20, 0.20, 0.20)` neutral** (indoors) — then add active lights on top. The indoor constant is taken **directly from retail**: `CellManager::ChangePosition` (0x004559B0) calls `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` whenever `CObjCell::seen_outside == 0`. The early-2026 guess at `(0.10, 0.09, 0.08)` was eyeballed; the retail value is both brighter and neutral. See §12 for the C# class.
## 4. Torch lights and `WeenieType.LightSource` ## 4. Torch lights and `WeenieType.LightSource`

View file

@ -0,0 +1,964 @@
# Indoor Cell Rendering Fix — Phase 1 Diagnostics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add five toggleable diagnostic probes that pinpoint where the EnvCell rendering chain breaks, so Phase 2's fix can target the actual failure point.
**Architecture:** Single `RenderingDiagnostics` static class in `AcDream.Core.Rendering` exposes five bool flags + a master toggle (env-var-initialized, runtime-settable). DebugVM mirrors them as live-toggle properties; DebugPanel exposes them as checkboxes. Probe call sites in `WbMeshAdapter` and `WbDrawDispatcher` emit one structured `[indoor-*]` line per event when the corresponding flag is on. The Holtburg Inn floor-missing bug is the test case — log output identifies which of six hypotheses (H1H6 in the spec) the failure matches.
**Tech Stack:** C# .NET 10, xUnit (test framework), Silk.NET OpenGL (rendering), Chorizite.OpenGLSDLBackend (WB ObjectMeshManager).
**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-indoor-cell-rendering-fix-design.md)
---
## File Structure
| File | Status | Responsibility |
|---|---|---|
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | NEW | Static class with five `bool` properties + master toggle. Env-var read at startup; runtime-settable. |
| `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` | NEW | Verify default values and get/set behavior of the diagnostic flags. |
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | MODIFY | Add five mirror properties that forward to `RenderingDiagnostics`. |
| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | MODIFY | Add an "Indoor rendering" subsection in `DrawDiagnostics` with six checkboxes. |
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY | Emit `[indoor-upload] requested` on first `IncrementRefCount` for an EnvCell id; emit `[indoor-upload] completed` in `Tick()` when WB's staged drain produces that id's `ObjectMeshData`. |
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFY | Emit `[indoor-walk]` + `[indoor-cull]` in `WalkVisibleEntities` per cell entity; emit `[indoor-lookup]` and `[indoor-xform]` in `DrawAccumulated` per cell-entity render-data lookup + composed transform. |
---
## Task 1: Create `RenderingDiagnostics` static class
**Files:**
- Create: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
- [ ] **Step 1: Write the file**
The class mirrors `AcDream.Core.Physics.PhysicsDiagnostics` exactly — same env-var-init pattern, same get/set, same XML comments style. Five individual probe flags + one `IndoorAll` master. The master setter cascades to all five.
```csharp
using System;
namespace AcDream.Core.Rendering;
/// <summary>
/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
/// rendering pipeline. Initialized from env vars at process start;
/// flippable at runtime via the DebugPanel mirror. Log call sites read
/// these statics so a checkbox toggle takes effect on the next frame
/// without relaunching.
///
/// <para>
/// Mirrors the L.2a <see cref="AcDream.Core.Physics.PhysicsDiagnostics"/>
/// pattern. The master <see cref="IndoorAll"/> toggle is the user's
/// common case — flipping it cascades to all five probe flags.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md</c>.
/// </para>
/// </summary>
public static class RenderingDiagnostics
{
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-walk]</c> line per visible cell entity per second:
/// entity id, world position, parent cell id, landblock visible flag,
/// AABB-visible flag, "in visible cells" flag, drew flag.
/// Initial state from <c>ACDREAM_PROBE_INDOOR_WALK=1</c>.
/// </summary>
public static bool ProbeIndoorWalkEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-lookup]</c>
/// line per visible cell entity per second: render-data hit/miss,
/// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
/// Initial state from <c>ACDREAM_PROBE_INDOOR_LOOKUP=1</c>.
/// </summary>
public static bool ProbeIndoorLookupEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbMeshAdapter</c> emits two lines per EnvCell id:
/// <c>[indoor-upload] requested</c> on first IncrementRefCount and
/// <c>[indoor-upload] completed</c> when WB's staged drain produces
/// its <c>ObjectMeshData</c>. Missing "completed" lines indicate WB
/// silently returned null (hypothesis H1).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_UPLOAD=1</c>.
/// </summary>
public static bool ProbeIndoorUploadEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher</c> emits one <c>[indoor-xform]</c>
/// line per visible cell entity per second: cell-geometry SetupPart's
/// composed world matrix translation. Disambiguates transform
/// double-apply (hypothesis H5).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_XFORM=1</c>.
/// </summary>
public static bool ProbeIndoorXformEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// When true, <c>WbDrawDispatcher.WalkVisibleEntities</c> emits one
/// <c>[indoor-cull]</c> line per cell entity that gets culled, with
/// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
/// cull bugs (hypothesis H3).
/// Initial state from <c>ACDREAM_PROBE_INDOOR_CULL=1</c>.
/// </summary>
public static bool ProbeIndoorCullEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
|| Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
/// <summary>
/// Master toggle. Reading reflects the AND of all five flags
/// (true only when every probe is on). Writing cascades — setting
/// to <see langword="true"/> turns ALL five flags on; setting to
/// <see langword="false"/> turns ALL five off.
/// </summary>
public static bool IndoorAll
{
get => ProbeIndoorWalkEnabled
&& ProbeIndoorLookupEnabled
&& ProbeIndoorUploadEnabled
&& ProbeIndoorXformEnabled
&& ProbeIndoorCullEnabled;
set
{
ProbeIndoorWalkEnabled = value;
ProbeIndoorLookupEnabled = value;
ProbeIndoorUploadEnabled = value;
ProbeIndoorXformEnabled = value;
ProbeIndoorCullEnabled = value;
}
}
/// <summary>
/// Helper for probe call sites. Returns <see langword="true"/> when
/// the low 16 bits of <paramref name="id"/> are ≥ 0x0100 — the AC
/// convention for EnvCell (indoor) cells, as opposed to outdoor cells
/// in the 8×8 landblock grid (0x00010x0040).
/// </summary>
public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug`
Expected: 0 errors, 0 warnings.
- [ ] **Step 3: Commit**
```bash
git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs
git commit -m "$(cat <<'EOF'
feat(diagnostics): RenderingDiagnostics static class for indoor probes
Five toggleable bool flags + master IndoorAll cascade, mirroring the
L.2a PhysicsDiagnostics pattern. Env vars at startup, runtime-settable
via DebugPanel mirrors (added next task). Probe call sites and DebugVM
wiring follow in subsequent tasks.
Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Unit-test `RenderingDiagnostics`
**Files:**
- Create: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
public sealed class RenderingDiagnosticsTests
{
[Fact]
public void IndoorAll_True_TurnsAllFlagsOn()
{
// Reset all flags off first to make the test deterministic
// regardless of env-var state on the test runner.
RenderingDiagnostics.ProbeIndoorWalkEnabled = false;
RenderingDiagnostics.ProbeIndoorLookupEnabled = false;
RenderingDiagnostics.ProbeIndoorUploadEnabled = false;
RenderingDiagnostics.ProbeIndoorXformEnabled = false;
RenderingDiagnostics.ProbeIndoorCullEnabled = false;
RenderingDiagnostics.IndoorAll = true;
Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled);
Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled);
Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled);
Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled);
Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled);
Assert.True(RenderingDiagnostics.IndoorAll);
}
[Fact]
public void IndoorAll_False_TurnsAllFlagsOff()
{
RenderingDiagnostics.IndoorAll = true; // start from all-on
RenderingDiagnostics.IndoorAll = false;
Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled);
Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled);
Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled);
Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled);
Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled);
Assert.False(RenderingDiagnostics.IndoorAll);
}
[Fact]
public void IndoorAll_OneOff_ReadsAsFalse()
{
RenderingDiagnostics.IndoorAll = true;
RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
Assert.False(RenderingDiagnostics.IndoorAll);
}
[Theory]
[InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
[InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
[InlineData(0x00000100ul, true)] // indoor cell minimum
[InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
[InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
[InlineData(0xA9B401FFul, true)] // indoor near top of range
public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
{
Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
}
}
```
- [ ] **Step 2: Run tests — expect failure on first build**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RenderingDiagnostics" -c Debug --nologo`
Expected: Build green (Task 1 already implemented the class). All 7 tests pass (1 cascade-on + 1 cascade-off + 1 partial-off + 4 IsEnvCellId rows).
If any test fails, the implementation in Task 1 has a bug — go back and fix.
- [ ] **Step 3: Commit**
```bash
git add tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs
git commit -m "$(cat <<'EOF'
test(diagnostics): RenderingDiagnostics cascade + IsEnvCellId rows
Covers the master IndoorAll cascade (both directions) and the IsEnvCellId
helper's 0x0100 boundary check across outdoor cells, indoor cells, and
landblock-prefixed forms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Mirror `RenderingDiagnostics` into `DebugVM`
**Files:**
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
- [ ] **Step 1: Read DebugVM and find the existing `ProbeBuilding` mirror block**
Find the `ProbeBuilding` property (around line 270) — that's an existing live-mirror to `PhysicsDiagnostics.ProbeBuildingEnabled`. New mirrors go immediately AFTER `ProbeAutoWalk` (next property in the file), in a new clearly-commented block.
- [ ] **Step 2: Add `using AcDream.Core.Rendering;` at the top of `DebugVM.cs`**
If the using statement is already present, skip. Otherwise insert alphabetically after `using AcDream.Core.Physics;`.
- [ ] **Step 3: Append the five mirror properties to the file**
Find the closing brace of the last existing property block (after `ProbeAutoWalk` or the last `Probe*` property). Insert this block before the class's closing brace:
```csharp
// ── Indoor rendering diagnostics (2026-05-19) ───────────────────
// Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles
// take effect on the next render frame without relaunching.
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorWalkEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_WALK</c>).
/// </summary>
public bool ProbeIndoorWalk
{
get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorLookupEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_LOOKUP</c>).
/// </summary>
public bool ProbeIndoorLookup
{
get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorUploadEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_UPLOAD</c>).
/// </summary>
public bool ProbeIndoorUpload
{
get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorXformEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_XFORM</c>).
/// </summary>
public bool ProbeIndoorXform
{
get => RenderingDiagnostics.ProbeIndoorXformEnabled;
set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.ProbeIndoorCullEnabled</c>
/// (env var <c>ACDREAM_PROBE_INDOOR_CULL</c>).
/// </summary>
public bool ProbeIndoorCull
{
get => RenderingDiagnostics.ProbeIndoorCullEnabled;
set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>RenderingDiagnostics.IndoorAll</c> — toggles all
/// five indoor probes together.
/// </summary>
public bool ProbeIndoorAll
{
get => RenderingDiagnostics.IndoorAll;
set => RenderingDiagnostics.IndoorAll = value;
}
```
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; new properties compile.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
git commit -m "$(cat <<'EOF'
feat(debugvm): mirror RenderingDiagnostics indoor probes
Live-toggle wrappers for the five indoor-rendering probe flags plus the
ProbeIndoorAll master cascade. Pattern matches existing ProbeResolve /
ProbeCell / ProbeBuilding / ProbeAutoWalk mirrors so a checkbox flip in
the DebugPanel takes effect on the next frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Expose probes in `DebugPanel` Diagnostics group
**Files:**
- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
- [ ] **Step 1: Find `DrawDiagnostics(IPanelRenderer r)` method**
Open the file. Find the method at approximately line 226. The existing pattern reads probe values into locals at the top of the method, then conditionally re-assigns through checkboxes. The new indoor probes follow the same shape, appended after the last existing probe checkbox.
- [ ] **Step 2: Read the locals + checkboxes at the bottom of the existing block**
Find the line that says `if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;` or similar last existing probe checkbox in `DrawDiagnostics`. New checkboxes go immediately AFTER this line, before the method's closing brace.
- [ ] **Step 3: Insert the new checkboxes**
Before the closing brace of `DrawDiagnostics`, insert:
```csharp
// ── Indoor rendering diagnostics (2026-05-19) ───────────────
// Pinpoint where the EnvCell rendering chain breaks for
// hypothesis-driven Phase 2 fix. Spec:
// docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
r.Separator();
r.Text("Indoor rendering (envCell):");
bool probeIndoorAll = _vm.ProbeIndoorAll;
bool probeIndoorWalk = _vm.ProbeIndoorWalk;
bool probeIndoorLookup = _vm.ProbeIndoorLookup;
bool probeIndoorUpload = _vm.ProbeIndoorUpload;
bool probeIndoorXform = _vm.ProbeIndoorXform;
bool probeIndoorCull = _vm.ProbeIndoorCull;
if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll;
if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk;
if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup;
if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload;
if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
```
Note: `r.Separator()` and `r.Text(string)` are the existing `IPanelRenderer` API methods used elsewhere in the file. If they don't exist, drop those two lines (the checkboxes still work standalone).
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
Expected: 0 errors.
If `r.Separator()` / `r.Text()` aren't on `IPanelRenderer`, the build will fail. Remove those two lines and re-build.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
git commit -m "$(cat <<'EOF'
feat(debugpanel): "Indoor rendering" probe checkboxes
Six checkboxes (ALL master + five individual probes) in the existing
DrawDiagnostics block. Toggling flips the corresponding
RenderingDiagnostics.Probe* flag live via DebugVM forwarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Instrument `WbMeshAdapter` with `[indoor-upload]` probes
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
The upload probe has TWO emission points:
1. `IncrementRefCount` — emits `requested` on the first call for an EnvCell id (gated by the existing `_metadataPopulated.Add(id)` first-call check).
2. `Tick()` — emits `completed` when WB's `StagedMeshData` drain produces an `ObjectMeshData` whose `ObjectId` is in our pending-EnvCell set.
- [ ] **Step 1: Add the pending-EnvCell tracking field + `using` statement**
Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
Find the field declarations near the top of the class (around line 34 — `_metadataPopulated`). Add immediately after:
```csharp
/// <summary>
/// EnvCell ids we've requested via PrepareMeshDataAsync but not yet
/// seen completion for in Tick(). Used by the [indoor-upload] probe
/// to log requested + completed pairs. Cleared per completion;
/// missing completions after a few seconds indicate WB silently
/// returned null (hypothesis H1 in the design spec).
/// </summary>
private readonly HashSet<ulong> _pendingEnvCellRequests = new();
```
- [ ] **Step 2: Emit `[indoor-upload] requested` in `IncrementRefCount`**
Find the `IncrementRefCount(ulong id)` method (around line 116). Inside the `if (_metadataPopulated.Add(id))` block, immediately AFTER the `_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);` line, add:
```csharp
// [indoor-upload] requested probe — only for EnvCell ids.
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
{
_pendingEnvCellRequests.Add(id);
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
}
```
- [ ] **Step 3: Emit `[indoor-upload] completed` in `Tick`**
Find the `Tick()` method (around line 167). Replace the existing drain loop:
```csharp
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
_meshManager.UploadMeshData(meshData);
}
```
with:
```csharp
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
// [indoor-upload] completed probe — check BEFORE upload so we
// see what WB actually produced (vertex counts, parts) before
// any post-upload mutation.
bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled
&& _pendingEnvCellRequests.Remove(meshData.ObjectId);
var renderData = _meshManager.UploadMeshData(meshData);
if (isPendingEnvCell)
{
int parts = meshData.SetupParts?.Count ?? 0;
bool hasGeom = meshData.EnvCellGeometry is not null;
int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0;
bool uploadOk = renderData is not null;
Console.WriteLine(
$"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " +
$"isSetup={meshData.IsSetup} parts={parts} " +
$"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " +
$"uploadOk={uploadOk}");
}
}
```
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
git commit -m "$(cat <<'EOF'
feat(wb): [indoor-upload] probe for EnvCell mesh requests + completions
Instruments WbMeshAdapter at two sites:
- IncrementRefCount: on first call for an EnvCell id (low 16 bits ≥
0x0100), tag the id in _pendingEnvCellRequests and log
[indoor-upload] requested.
- Tick: when WB's StagedMeshData drains an ObjectMeshData whose
ObjectId matches a pending EnvCell, log [indoor-upload] completed
with parts count, EnvCellGeometry vertex count, and upload result.
Missing "completed" lines after "requested" identify hypothesis H1
(WB silently returns null from PrepareEnvCellMeshData).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Instrument `WbDrawDispatcher` walk + cull probes
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
The `WalkVisibleEntities` method (around line 280) does landblock visibility, per-entity AABB cull, and the `visibleCellIds` filter. Cell entities (entities whose `MeshRefs[0].GfxObjId` low-16-bits ≥ 0x0100) need probes at three decision sites: passed-all, culled-by-aabb, culled-by-visibleCellIds.
To rate-limit, maintain a per-cellId last-log frame counter as a class-level field.
- [ ] **Step 1: Add the rate-limit tracking field + `using` statement**
Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
Find the class field declarations. Add:
```csharp
/// <summary>
/// Per-cell-entity last-log frame number for rate-limiting the
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
/// </summary>
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
private int _indoorProbeFrameCounter;
private const int IndoorProbeRateLimitFrames = 30;
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per cellId. Caller must already have checked that an indoor
/// probe flag is enabled.
/// </summary>
private bool ShouldEmitIndoorProbe(ulong cellId)
{
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
{
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
return true;
}
return false;
}
```
- [ ] **Step 2: Bump the frame counter at the top of `Draw(...)`**
Find the `Draw` method (around line 339). At its very top, after the existing `_shader.Use();` line, add:
```csharp
_indoorProbeFrameCounter++;
```
- [ ] **Step 3: Replace the per-entity filter block in `WalkVisibleEntities`**
Find the per-entity loop in `WalkVisibleEntities` (around lines 313-335). The current shape (simplified):
```csharp
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
continue;
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
```
Replace the entire `foreach (var entity in entry.Entities)` body with this instrumented version:
```csharp
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
// result reused for all four probe checks below.
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
bool isCellEntity = RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
if (!cellInVis)
{
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& ShouldEmitIndoorProbe(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=visibleCellIds-miss " +
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
}
continue;
}
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
}
if (!aabbVisible)
{
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& ShouldEmitIndoorProbe(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=frustum " +
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
}
continue;
}
// Passed all filters — emit walk probe.
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
&& ShouldEmitIndoorProbe(cellProbeId))
{
Console.WriteLine(
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"meshRef0=0x{cellProbeId:X8} " +
$"meshRefCount={entity.MeshRefs.Count} " +
$"landblockVisible=true aabbVisible=true cellInVis=true");
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
```
Important: `ShouldEmitIndoorProbe(cellProbeId)` is intentionally called only once per probe-decision-site per cellId, so each cellId emits at most ONE line per frame across all four probe sites (whichever fires first).
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; the new field + helper compile; the instrumented loop builds cleanly.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "$(cat <<'EOF'
feat(dispatcher): [indoor-walk] + [indoor-cull] probes
Instruments WalkVisibleEntities to identify whether cell entities (first
MeshRef.GfxObjId low-16-bits ≥ 0x0100) pass all visibility filters or
get culled. Three emission paths:
- [indoor-cull] reason=visibleCellIds-miss — when the ParentCellId
filter rejects the entity.
- [indoor-cull] reason=frustum — when AABB frustum cull rejects.
- [indoor-walk] — when the entity passes all filters and reaches the
draw list.
Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via
_lastIndoorProbeFrame dictionary. Bumped from Draw()'s top.
Disambiguates hypothesis H3 (cull bug — cell entity dropped before
draw).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Instrument `WbDrawDispatcher` lookup + xform probes
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
These probes fire deeper in the per-MeshRef draw loop, where the render-data lookup happens and the `IsSetup` branch composes per-part transforms. The dispatcher's per-MeshRef body is around line 590-627.
- [ ] **Step 1: Find the per-MeshRef body and the IsSetup branch**
Open the file. Find the line `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` (or similar TryGetRenderData lookup inside the per-MeshRef draw loop). The relevant block is the if/else at line 607 (the `IsSetup` branch).
- [ ] **Step 2: Add the `[indoor-lookup]` probe at the lookup site**
Find the line that fetches the renderData (likely `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` or equivalent). Immediately AFTER that lookup and BEFORE the existing null/miss handling at line 595 (`if (diag) _meshesMissing++; continue;`), insert:
```csharp
// [indoor-lookup] probe — emit once per cell entity per sec.
ulong _lookupCellId = (ulong)gfxObjId;
if (RenderingDiagnostics.IsEnvCellId(_lookupCellId)
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
&& ShouldEmitIndoorProbe(_lookupCellId))
{
bool hit = renderData is not null;
bool isSetup = hit && renderData!.IsSetup;
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
int partsHit = 0, partsMiss = 0;
if (isSetup)
{
foreach (var (partId, _) in renderData!.SetupParts)
{
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
else partsMiss++;
}
}
bool hasEnvCellGeom = isSetup
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
Console.WriteLine(
$"[indoor-lookup] cellId=0x{_lookupCellId:X8} " +
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
}
```
Note: this probe emits BEFORE the null-renderData early-`continue`, so a null lookup still emits `hit=false`. That's intentional — it tells us if the lookup itself failed (hypothesis H1 fallout).
- [ ] **Step 3: Add the `[indoor-xform]` probe inside the IsSetup branch**
Find the `if (renderData.IsSetup && renderData.SetupParts.Count > 0)` block (line 607 in current code). Inside the `foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)` loop, AFTER the `var model = ComposePartWorldMatrix(...)` line, insert:
```csharp
// [indoor-xform] probe — only for the cell's synthetic
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
// line 1247). One line per cell per sec.
if ((partGfxObjId & 0x1_0000_0000UL) != 0
&& RenderingDiagnostics.ProbeIndoorXformEnabled
&& ShouldEmitIndoorProbe(partGfxObjId))
{
Console.WriteLine(
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
}
```
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: 0 errors.
- [ ] **Step 5: Test (existing tests, sanity)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Rendering" --no-build --nologo`
Expected: All Rendering tests (including new RenderingDiagnosticsTests) pass.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
git commit -m "$(cat <<'EOF'
feat(dispatcher): [indoor-lookup] + [indoor-xform] probes
Instruments the per-MeshRef draw loop in WbDrawDispatcher:
- [indoor-lookup]: per cell entity, dumps render-data hit/miss,
IsSetup, parts count, and a partsHit/partsMiss tally over the
SetupParts. Disambiguates hypothesis H2 (WB produces empty
ObjectRenderData with zero parts) and H6 (dispatcher fails to
traverse Setup).
- [indoor-xform]: only fires for the cell's synthetic geometry part
(the SetupPart whose GfxObjId has bit 32 set, per WB's
PrepareEnvCellMeshData cellGeomId convention). Logs the three
composed transform translations: entityWorld, meshRef.PartTransform,
partTransform, and the final composed matrix translation. Disambiguates
hypothesis H5 (transform double-apply — composedT lands at 2 ×
cellOrigin).
Rate-limited via existing _lastIndoorProbeFrame map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: Build + visual capture procedure
**Files:** none modified. Build verification + runtime data capture.
- [ ] **Step 1: Full solution build**
Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
Expected: 0 errors, 0 warnings. All projects compile.
- [ ] **Step 2: Run full test suite**
Run: `dotnet test AcDream.slnx -c Debug --nologo --no-build 2>&1 | tail -15`
Expected: New RenderingDiagnostics tests pass. Pre-existing failures in `DispatcherToMovementIntegrationTests`, `BSPStepUpTests`, and `MotionInterpreterTests` (8 total) remain — those are unrelated to this work. No NEW failures.
- [ ] **Step 3: Gracefully close any prior AcDream.App instance**
```powershell
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($proc) {
$proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
$proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
Start-Sleep -Seconds 3
}
```
- [ ] **Step 4: Launch with all indoor probes enabled**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_INDOOR_ALL = "1"
$logPath = "launch.log"
Remove-Item $logPath -ErrorAction SilentlyContinue
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath
```
Run this in the background (the launching tool supports `run_in_background: true`).
- [ ] **Step 5: User reproduces the bug**
In the running client:
- Wait until in-world at Holtburg (8-12 s after launch).
- Walk to Holtburg Inn (north of spawn — Fispur's Foodstuffs is visible).
- Stand at the doorway. Then step inside. Look at the floor.
- Walk around the inn interior.
- Close the client window (graceful close — close button, NOT taskkill).
- [ ] **Step 6: Grep the log for probe output**
```bash
grep -E "\[indoor-" launch.log | head -100
```
Expected: a mix of `[indoor-upload] requested`, `[indoor-upload] completed`, `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` lines for the Holtburg Inn cell IDs (0xA9B40100-ish range).
- [ ] **Step 7: Identify which hypothesis matches**
Compare the captured log against the hypothesis table in the spec (§3 of `2026-05-19-indoor-cell-rendering-fix-design.md`):
| Hypothesis | Probe pattern in log |
|---|---|
| H1 — WB silently returns null | `[indoor-upload] requested` lines exist but NO matching `completed` lines for cell ids |
| H2 — Empty batches | `[indoor-upload] completed ... cellGeomVerts=0` |
| H3 — Cull bug | `[indoor-cull]` lines for cell entity ids with `reason=visibleCellIds-miss` |
| H4 — Double-spawn | `[indoor-lookup] partCount=N` where N includes static object IDs that ALSO appear in the entity walk — cross-check against `[indoor-walk]` lines |
| H5 — Transform double-apply | `[indoor-xform] composedT` translation roughly 2× the cell's known world origin |
| H6 — MeshRefs structure | `[indoor-lookup] hit=true isSetup=true partCount>0 partsHit=0` (all parts missing) |
- [ ] **Step 8: Document the captured data + matched hypothesis**
Create a short investigation note at `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md` summarizing:
- The exact `[indoor-*]` log lines captured (or a representative subset).
- The matched hypothesis number.
- A one-line proposed fix sketch.
This file will be referenced by Phase 2's spec.
- [ ] **Step 9: Commit the capture note**
```bash
git add docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
git commit -m "$(cat <<'EOF'
docs(research): Phase 1 indoor probe capture — identifies hypothesis HX
[Replace HX with the matched hypothesis number, and summarize the
captured log evidence in 1-2 sentences.]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 10: Hand off to Phase 2 design**
The captured data is now the input to Phase 2's design. Either:
- Amend `docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md` with a Phase 2 section, OR
- Write a new spec `docs/superpowers/specs/YYYY-MM-DD-indoor-cell-rendering-phase2-fix-design.md` targeting the identified hypothesis.
The plan for Phase 2 follows the standard brainstorming → writing-plans → executing-plans flow.
---
## Acceptance Criteria
- [x] All eight tasks complete + committed.
- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
- [ ] Probe captured at Holtburg Inn produces enough log evidence to identify which of H1-H6 is the root cause.
- [ ] Capture note written and committed.
- [ ] Phase 2 design follow-up spec started.

View file

@ -0,0 +1,550 @@
# Indoor Cell Rendering Fix — Phase 2 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:** Surface WB's silent `PrepareEnvCellMeshData` failures via an exception-capturing continuation in `WbMeshAdapter`, identify the root cause for the 26 missing-completion cells, then implement the targeted fix that lands the indoor floor rendering.
**Architecture:** `WbMeshAdapter.IncrementRefCount` captures the `Task<ObjectMeshData?>` returned by WB's `PrepareMeshDataAsync` and attaches a `ContinueWith` that logs faulted-task exceptions + clean-null results for EnvCell IDs only. Gated by the existing `ProbeIndoorUploadEnabled` flag — zero cost when off. Component 3 (the actual fix) is data-driven: the captured exception type + message determines the surgical code change.
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, WorldBuilder's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager`. xUnit for any unit tests.
**Spec:** [`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md).
**Phase 1 capture:** [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../../research/2026-05-19-indoor-cell-rendering-probe-capture.md).
---
## File Structure
| File | Status | Responsibility |
|---|---|---|
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY (Task 1) | Capture `prepTask` from `PrepareMeshDataAsync`. Attach a `ContinueWith` for EnvCell IDs that emits `[indoor-upload] FAILED` on faulted tasks and `[indoor-upload] NULL_RESULT` on clean-null returns. |
| `launch.log` (and the user's walk-through) | NEW (Task 2) | Captured probe output. Drives Component 3's fix shape. Not committed. |
| `docs/research/2026-05-19-indoor-cell-rendering-cause.md` | NEW (Task 3) | One-page report documenting the captured exception type(s) + the chosen fix shape. Becomes Phase 2's "design closure" doc. |
| TBD-by-data (Component 3) | MODIFY (Task 4) | Fix shape depends on captured cause. Likely candidates: `WbMeshAdapter.PopulateMetadata`, `CellMesh.Build`, a guard at the dat-access call site, or a small WB fork patch. |
| `docs/research/2026-05-19-indoor-cell-rendering-verification.md` | NEW (Task 5) | Post-fix verification record: previously-missing cells now emit `[indoor-upload] completed`, visual confirmation. |
| `docs/plans/2026-04-11-roadmap.md` | MODIFY (Task 6) | Roadmap update: Phase 2 shipped, link to spec + research notes. |
---
## Task 1: Add exception-surfacing continuation in `WbMeshAdapter`
**Files:**
- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
- [ ] **Step 1: Add `using System.Linq;` and `using System.Threading.Tasks;` if missing**
Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Verify both `using System.Linq;` and `using System.Threading.Tasks;` are present at the top. Add them if not.
- [ ] **Step 2: Replace the fire-and-forget call with a captured task + continuation**
Find the `IncrementRefCount` method (around line 116). The current block looks like:
```csharp
public void IncrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
if (_metadataPopulated.Add(id))
{
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
// [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}");
}
}
}
```
Replace the `_metadataPopulated.Add(id)` block body with this exact content (note: the `_ = _meshManager.PrepareMeshDataAsync(...)` line becomes `var prepTask = ...` — capture the task instead of discarding it):
```csharp
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
// [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}");
// Phase 2 — surface what WB's catch block silently swallows.
// ObjectMeshManager.PrepareMeshData has try/catch at line 589
// that calls _logger.LogError on exceptions and returns null.
// We construct ObjectMeshManager with NullLogger so the log
// goes nowhere. This continuation captures the same data
// (scoped to EnvCell ids only). Runs on ThreadPool; non-
// blocking. Zero cost when probe is off.
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);
}
```
- [ ] **Step 3: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: 0 errors, 0 warnings (any new warnings about discarded tasks are fixed by the `_ = prepTask.ContinueWith(...)` assignment).
- [ ] **Step 4: Run tests (sanity)**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Rendering" -c Debug --nologo --no-build`
Expected: All 130 Rendering tests still pass (the change doesn't touch any tested code path — `WbMeshAdapter.IncrementRefCount` isn't covered by unit tests).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
git commit -m "$(cat <<'EOF'
feat(wb): surface WB-swallowed exceptions for EnvCell upload failures
Phase 1 confirmed 26/123 Holtburg cells silently fail in WB's
PrepareEnvCellMeshData / PrepareMeshData. WB's catch block at
ObjectMeshManager.cs:589 calls _logger.LogError(ex, ...) — but we
construct ObjectMeshManager with NullLogger, so the log is dropped.
Capture the Task from PrepareMeshDataAsync (previously fire-and-forget)
and attach a ContinueWith that, for EnvCell ids only when the probe
is on, logs:
[indoor-upload] FAILED cellId=0x... exception=<Type>: <Message>
stack=[<top 3 frames>]
[indoor-upload] NULL_RESULT cellId=0x...
Runs on ThreadPool — non-blocking. Zero cost when ProbeIndoorUploadEnabled
is off. AggregateException is unwrapped to InnerException for readability.
Stack truncated to top 3 frames.
Next: capture procedure, identify cause, target the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Capture procedure — run client, identify cause
This task is operator-driven, not subagent-driven. The user (not a subagent) walks the client. Subagent role is limited to launching + analyzing the log.
**Files:**
- New: `launch.log` (transient — not committed)
- [ ] **Step 1: Full solution build (sanity)**
Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 2: Gracefully close any prior `AcDream.App` instance**
```powershell
$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
if ($proc) {
$proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
$proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
Start-Sleep -Seconds 3
}
```
- [ ] **Step 3: Launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_INDOOR_UPLOAD = "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 in background via `run_in_background: true`.
- [ ] **Step 4: User walks Holtburg**
User waits for the client to reach in-world (~8-12 s), then:
- Walks into Holtburg Inn (where the floor was missing in Phase 1).
- Walks into 2-3 other nearby buildings to capture varied failure causes.
- Closes the client window with the close button (graceful — NOT taskkill).
- [ ] **Step 5: Analyze the log**
```powershell
$lines = Get-Content launch.log | Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' }
Write-Host "Total failure lines: $($lines.Count)"
Write-Host ""
Write-Host "=== Distinct exception types (FAILED) ==="
$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } |
ForEach-Object { if ($_ -match 'exception=(\w+):') { $matches[1] } } |
Group-Object | Sort-Object Count -Descending | Format-Table -AutoSize
Write-Host "=== Distinct NULL_RESULT count ==="
($lines | Where-Object { $_ -match 'NULL_RESULT' }).Count
Write-Host ""
Write-Host "=== Sample FAILED lines ==="
$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } | Select-Object -First 10
Write-Host ""
Write-Host "=== Sample NULL_RESULT lines ==="
$lines | Where-Object { $_ -match '\[indoor-upload\] NULL_RESULT' } | Select-Object -First 5
```
Verify the previously-failing cells (from Phase 1: `0xA9B40100`, `0xA9B40111`, `0xA9B40112`, etc.) now appear in either FAILED or NULL_RESULT.
If they DON'T appear:
- Confirm the probe flag is on (check `$env:ACDREAM_PROBE_INDOOR_UPLOAD` reads `"1"`).
- Confirm the user actually walked into the failing cells.
- Possible BUG: the continuation isn't firing — check Task 1's edits for typos.
---
## Task 3: Write the cause report
**Files:**
- Create: `docs/research/2026-05-19-indoor-cell-rendering-cause.md`
- [ ] **Step 1: Write the report based on Task 2's output**
Create the file with this structure (replace bracketed sections with captured data):
```markdown
# Indoor Cell Rendering — Phase 2 Cause Report
**Date:** 2026-05-19
**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
**Capture method:** Task 1's `ContinueWith` surfaced WB's swallowed exceptions for EnvCell IDs.
## Cause(s)
[Replace this section with the captured findings. Example shape:]
Two distinct failure modes captured at Holtburg:
1. **`KeyNotFoundException` — N cells affected** — Exception thrown from `PrepareCellStructMeshData` line XXX when trying to look up surface `0x08001234`. Affected cells: `0xA9B40100`, `0xA9B40111`, ...
2. **`NULL_RESULT` — M cells affected** — WB's `ResolveId` returned empty for `EnvironmentId 0xD000XXXX`, causing `PrepareEnvCellMeshData` to skip the cellGeometry branch and produce an empty result. Affected cells: ...
[OR if only one cause is observed:]
Single failure mode: [exception type] thrown in [location] for all 26 cells. Root cause: [analysis].
## Sample log lines
```
[paste 5-10 actual captured FAILED / NULL_RESULT lines here]
```
## Proposed fix
[Concrete code change for each distinct cause. For example:]
- For `KeyNotFoundException` on surface lookup: add a null-guard in `WbMeshAdapter.PopulateMetadata` AND skip the failing surface in our acdream-side processing.
- For `NULL_RESULT` from missing `EnvironmentId`: log + skip with a sentinel render-data so the dispatcher gracefully draws nothing instead of failing silently.
Each fix is a single-file change. Task 4 of this plan implements them.
## Verification approach
After Task 4's fix:
- Re-launch with the same probe flag.
- Confirm previously-failing cells now emit `[indoor-upload] completed` lines.
- Visual: floor renders in Holtburg Inn.
```
- [ ] **Step 2: Commit**
```bash
git add docs/research/2026-05-19-indoor-cell-rendering-cause.md
git commit -m "$(cat <<'EOF'
docs(research): Phase 2 cause report — <one-line summary of finding>
Captured at Holtburg with the ContinueWith-based exception surfacer
from Task 1. <Describe finding in 2-3 sentences: which exception types
fired, for how many cells, the root cause.>
Fix shape decided: <one sentence>. Implemented in next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Apply the targeted fix
**The fix shape is unknown until Task 2 captures.** This task's code is data-driven. The plan below lists the four most likely fix shapes; the implementer picks the matching one(s) and implements them.
### 4a — If the cause is `KeyNotFoundException` / missing dat record
Most likely path: WB's `PrepareCellStructMeshData` calls `_dats.Portal.TryGet<Surface>(surfaceId, out var surface)`, gets `false`, then crashes when later code assumes non-null.
**Files:**
- Modify: TBD by exception stack — likely a WB fork patch OR a guard at our acdream call site.
- [ ] **Step 1: Open the throwing file based on the exception stack trace**
The probe line will show:
```
stack=[at PrepareCellStructMeshData in ObjectMeshManager.cs:line | at PrepareEnvCellMeshData in ObjectMeshManager.cs:line | ...]
```
Open that file at that line. Confirm the missing-dat-record assumption.
- [ ] **Step 2: Patch shape (WB fork, if in WB)**
In `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`, add a null-guard at the throwing line:
```csharp
// Pre-Phase-2: WB assumed every surface in envCell.Surfaces was
// resolvable. Some Holtburg cells reference surfaces that aren't in the
// loaded portal dat, causing a NullRef in the throwing line below.
// Guard: skip the surface if it doesn't resolve.
if (!_dats.Portal.TryGet<Surface>(surfaceId, out var surface))
{
continue; // or: surface = _fallbackSurface; whichever fits
}
```
(Exact code depends on the stack. The implementer reads the actual throwing line and adapts.)
- [ ] **Step 3: Build, capture, verify**
```bash
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
```
Then re-run Task 2's launch + capture. Confirm:
- Previously-failing cells now have `[indoor-upload] completed` lines.
- No new `[indoor-upload] FAILED` lines for those cells.
- [ ] **Step 4: Commit**
```bash
git add references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs
# OR whatever file was patched
git commit -m "$(cat <<'EOF'
fix(wb): null-guard for missing surface in PrepareCellStructMeshData
Phase 2 capture found <N> Holtburg cells silently failing with
<ExceptionType> thrown at <file>:<line> when WB tried to look up
surface 0x... that isn't resolvable in the loaded portal dat.
Patch: <one-sentence description of the guard>.
Visual-verified: floor now renders in Holtburg Inn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
### 4b — If the cause is `NULL_RESULT` (clean null return from WB)
WB's `PrepareMeshData` returns null without throwing. Examined paths in the WB source:
- Line 568: `_dats.Portal.TryGet<Environment>(envId, ...)` fails → returns null.
- Line 583: `type == DBObjType.Unknown` (ResolveId didn't classify the record) → returns null.
**Files:**
- Modify: probably WbMeshAdapter to detect and log, then either accept the cell as "no geometry" gracefully OR investigate the dat issue.
- [ ] **Step 1: Read which path triggered**
Look at the `NULL_RESULT` cells' EnvironmentId values. If the EnvironmentId looks corrupt or out of range, the dat is the issue. If it looks valid, WB's `ResolveId` is broken for that record.
- [ ] **Step 2: Add a guard at our acdream call site OR patch WB**
Depending on the finding:
- **If dat is genuinely missing data**: skip the cell with a warning. Don't try to render its mesh. Log once via memory.
- **If WB's ResolveId mis-classifies**: patch WB or work around by pre-checking with our own `_dats.Get<EnvCell>(envCellId)` before calling `IncrementRefCount`.
- [ ] **Step 3: Build, capture, verify, commit** (same pattern as 4a Step 3-4).
### 4c — If the cause is a `NullReferenceException` in our code path
Less likely but possible — if `PopulateMetadata` or `CellMesh.Build` crashes when invoked from a worker thread.
**Files:**
- Modify: the specific acdream file the stack trace points to.
- [ ] **Step 1: Read the throwing line**
- [ ] **Step 2: Add the appropriate null-guard**
- [ ] **Step 3: Build, capture, verify, commit.**
### 4d — If the cause is something else entirely
If the captured exception type doesn't match 4a-4c, **STOP and re-design**. The fix shape needs the implementer's judgment + possibly a fresh brainstorm session. Don't paper over the cause with a generic try/catch.
---
## Task 5: Verification + visualization
**Files:**
- Create: `docs/research/2026-05-19-indoor-cell-rendering-verification.md`
- [ ] **Step 1: Re-launch with the probe and re-walk Holtburg**
Same as Task 2 Steps 2-4, but expectation flipped: `[indoor-upload] FAILED` / `NULL_RESULT` lines for previously-failing cells should NOT appear; `[indoor-upload] completed` lines should appear instead.
- [ ] **Step 2: Visual verification by user**
User walks into Holtburg Inn AND the other buildings whose cells were previously missing. Expected: floors visible, no missing geometry.
- [ ] **Step 3: Write the verification report**
Create the file documenting:
```markdown
# Indoor Cell Rendering — Phase 2 Verification
**Date:** 2026-05-19
**Outcome:** Floor renders in Holtburg Inn.
## Probe re-capture
After Task 4's fix:
- Previously-failing cells: <list e.g. `0xA9B40100`, `0xA9B40111`, ...>
- Now emit `[indoor-upload] completed cellId=0x... isSetup=True hasEnvCellGeom=True cellGeomVerts=<N> uploadOk=True`
- No new `[indoor-upload] FAILED` or `NULL_RESULT` lines for these cells.
## Visual confirmation
User walked into:
- Holtburg Inn — floor visible. ✓
- <other buildings tested> — floor visible. ✓
## Regressions checked
- Outdoor terrain still renders correctly. ✓
- NPCs, mobs, scenery still render. ✓
- No new build warnings, no new test failures.
## Closes
This concludes Phase 2 of the indoor cell rendering fix.
```
- [ ] **Step 4: Commit**
```bash
git add docs/research/2026-05-19-indoor-cell-rendering-verification.md
git commit -m "$(cat <<'EOF'
docs(research): Phase 2 verification — floor renders in Holtburg Inn
Post-fix re-capture confirms previously-failing cells now emit
[indoor-upload] completed. Visual verification by user confirms
floors visible in Holtburg Inn and <other tested buildings>.
Phase 2 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Roadmap update
**Files:**
- Modify: `docs/plans/2026-04-11-roadmap.md`
- [ ] **Step 1: Read the roadmap's "shipped" section**
Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (likely near the top, in a "shipped" table or chronological list).
- [ ] **Step 2: Add an entry for Phase 2 indoor cell rendering fix**
Add an entry matching the existing pattern of shipped-row entries. Example shape:
```markdown
| <next row number> | 2026-05-19 | Indoor cell rendering — Phase 1 (diagnostics) + Phase 2 (fix) | Surfaced + fixed WB's silent failure for 26/123 Holtburg cells. Spec at [phase 1](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md) + [phase 2](../superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md). Cause: <one-line>. Fix: <one-line>. Visual-verified at Holtburg Inn. |
```
(Read the actual existing row format and match it.)
- [ ] **Step 3: Commit**
```bash
git add docs/plans/2026-04-11-roadmap.md
git commit -m "$(cat <<'EOF'
docs(roadmap): Phase 2 indoor cell rendering fix shipped
Phase 1 diagnostics + Phase 2 fix landed today. Indoor floor rendering
restored for Holtburg cells previously missing due to WB silent
failure. Spec, plan, and verification documents committed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Acceptance Criteria
- [ ] Task 1 commits: `WbMeshAdapter.IncrementRefCount` attaches the continuation. `dotnet build` clean.
- [ ] Task 2 capture: `[indoor-upload] FAILED` or `NULL_RESULT` lines fire for previously-failing cells. Distinct cause(s) identified.
- [ ] Task 3 cause report: documented in `docs/research/2026-05-19-indoor-cell-rendering-cause.md`.
- [ ] Task 4 fix: applied + committed. Build clean. Tests clean (no new failures; pre-existing 8 physics/input failures unchanged).
- [ ] Task 5 verification: post-fix probe re-capture confirms `[indoor-upload] completed` for previously-failing cells. User visually confirms floor renders in Holtburg Inn.
- [ ] Task 6 roadmap update: shipped row added.
---
## Subagent dispatch notes
- **Task 1** is mechanical (well-specified code edit) — dispatch to Sonnet.
- **Task 2** is operator-driven — the controller (parent) drives the launch + capture, not a subagent. The user MUST walk the client.
- **Task 3** is analytical (interpret captured data) — controller writes inline, or dispatch a Sonnet subagent with the captured log as context.
- **Task 4** is judgment-intensive (fix shape depends on data) — controller writes inline. If complex, a fresh brainstorm may be needed.
- **Task 5** is similar to Task 2 (user-driven walk + analysis).
- **Task 6** is mechanical — dispatch to Sonnet OR controller writes inline.

View file

@ -0,0 +1,248 @@
# 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.

View file

@ -0,0 +1,189 @@
# 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 13 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.

@ -1 +1 @@
Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81 Subproject commit 34460c44d7fb921afa50ee30288a53236f50f451

View file

@ -330,6 +330,21 @@ public sealed class CellVisibility
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon; local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
} }
/// <summary>
/// Brute-force scan of every loaded cell to test whether
/// <paramref name="worldPoint"/> is inside any of them. Does not touch
/// the camera cache (<see cref="_lastCameraCell"/>), so this is safe
/// to call alongside <see cref="ComputeVisibility"/> in the same frame
/// for a different position (e.g. player position when the camera is
/// in third-person chase mode).
/// </summary>
public bool IsInsideAnyCell(Vector3 worldPoint)
{
foreach (var cell in _cellLookup.Values)
if (PointInCell(worldPoint, cell)) return true;
return false;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// GetVisibleCells (BFS) // GetVisibleCells (BFS)
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View file

@ -6848,6 +6848,19 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos); var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null; bool cameraInsideCell = visibility?.CameraCell is not null;
// Lighting decisions (sun zeroed, indoor ambient applied) must
// track the PLAYER's cell, not the camera's. In third-person
// chase mode the camera enters interiors before the player body
// does, so a camera-based trigger flips the scene to indoor
// lighting prematurely. Retail's CellManager::ChangePosition
// @ 0x004559B0 reads CObjCell::seen_outside on the player's
// current cell — that's the semantics we want here. When the
// player isn't in player mode (orbit / fly debug camera) we
// fall back to the camera trigger.
bool playerInsideCell = (_playerMode && _playerController is not null)
? _cellVisibility.IsInsideAnyCell(_playerController.Position)
: cameraInsideCell;
// Phase C.1: tick retail PhysicsScript particle hooks. Named // Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by // retail decomp confirms SkyObject.PesObjectId is copied by
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
@ -6861,7 +6874,7 @@ public sealed class GameWindow : IDisposable
// the scene-lighting UBO once per frame. Every shader that // the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the // consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky. // frame — terrain, static mesh, instanced mesh, sky.
UpdateSunFromSky(kf, cameraInsideCell); UpdateSunFromSky(kf, playerInsideCell);
Lighting.Tick(camPos); Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction); Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
@ -8319,18 +8332,28 @@ public sealed class GameWindow : IDisposable
/// Derive the current sun (directional light, slot 0 of the UBO) /// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>, /// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to /// plus the cell ambient. Indoor cells force the sun intensity to
/// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient. /// zero and substitute a flat 0.2 white ambient — exact retail
/// behavior per <c>CellManager::ChangePosition</c> @ 0x004559B0,
/// which calls <c>SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)</c>
/// when the player's <c>CObjCell::seen_outside</c> flag is 0.
/// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
/// The trigger is the PLAYER's cell, not the camera's — third-person
/// chase camera enters interiors before the player body does, and
/// retail keys lighting off the player position.
/// </summary> /// </summary>
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
{ {
// Sun direction: points FROM the sun TOWARDS the world. Our // Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the // shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun. // surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
if (cameraInsideCell) if (playerInsideCell)
{ {
// Dungeon default per r13 §3 — warm-dark ambient, no sun. // Indoor default — retail's flat 0.2 neutral ambient, sun
// zeroed. See xref to retail decomp in the doc comment above.
Lighting.Sun = new AcDream.Core.Lighting.LightSource Lighting.Sun = new AcDream.Core.Lighting.LightSource
{ {
Kind = AcDream.Core.Lighting.LightKind.Directional, Kind = AcDream.Core.Lighting.LightKind.Directional,
@ -8340,7 +8363,7 @@ public sealed class GameWindow : IDisposable
Range = 1f, Range = 1f,
}; };
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f), AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f),
SunColor: System.Numerics.Vector3.Zero, SunColor: System.Numerics.Vector3.Zero,
SunDirection: sunToWorld); SunDirection: sunToWorld);
} }

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain; using AcDream.Core.Terrain;
using AcDream.Core.World; using AcDream.Core.World;
using Chorizite.OpenGLSDLBackend.Lib; using Chorizite.OpenGLSDLBackend.Lib;
@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private bool _disposed; private bool _disposed;
/// <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;
}
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1. // Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen; private int _entitiesSeen;
private int _entitiesDrawn; private int _entitiesDrawn;
@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to /// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field. /// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
///
/// <para>
/// When <paramref name="indoorProbeState"/> is non-null the method emits
/// <c>[indoor-cull]</c> lines for cell entities rejected by the
/// visibleCellIds or frustum filters, and <c>[indoor-walk]</c> lines for
/// cell entities that pass all filters. Rate-limited by
/// <see cref="IndoorProbeState"/>. Pass <see langword="null"/> (the default)
/// to disable all probe emission — used by the test-friendly
/// <see cref="WalkEntities"/> overload.
/// </para>
/// </summary> /// </summary>
internal static void WalkEntitiesInto( internal static void WalkEntitiesInto(
IEnumerable<LandblockEntry> landblockEntries, IEnumerable<LandblockEntry> landblockEntries,
@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet<uint>? visibleCellIds, HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds, HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch, List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result) ref WalkResult result,
IndoorProbeState? indoorProbeState = null)
{ {
scratch.Clear(); scratch.Clear();
result.EntitiesWalked = 0; result.EntitiesWalked = 0;
@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{ {
if (entity.MeshRefs.Count == 0) continue; if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null // Detect cell entity for indoor probes — first MeshRef.GfxObjId
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
// result reused for all probe checks below.
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
bool isCellEntity = indoorProbeState is not null
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
if (!cellInVis)
{
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=visibleCellIds-miss " +
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
}
continue; continue;
}
// Per-entity AABB frustum cull (perf #3). Animated entities bypass — // Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless. // they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{ {
if (entity.AabbDirty) entity.RefreshAabb(); if (entity.AabbDirty) entity.RefreshAabb();
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax)) aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
continue; }
if (!aabbVisible)
{
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(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
&& indoorProbeState!.ShouldEmit(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++; result.EntitiesWalked++;
@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet<uint>? animatedEntityIds = null) HashSet<uint>? animatedEntityIds = null)
{ {
_shader.Use(); _shader.Use();
_indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection; var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp); _shader.SetMatrix4("uViewProjection", vp);
@ -391,6 +475,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload // A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames) // that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame. // instead of allocating a fresh List<(WorldEntity, int)> per frame.
//
// Pass an IndoorProbeState when any indoor probe is active so the static
// WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
// lines without needing access to instance fields. Null = probes off.
IndoorProbeState? probeState = null;
if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
{
// _currentFrame is snapped at construction time. Construct
// once per Draw() call only — a second construction within
// the same frame would stamp the dictionary with the
// (already-advanced) counter value, suppressing the second
// pass's emissions for IndoorProbeRateLimitFrames frames.
// Today Draw() is called exactly once per frame; if a
// future refactor adds a shadow / reflection / second pass,
// this assumption needs revisiting.
probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
}
var walkResult = default(WalkResult); var walkResult = default(WalkResult);
WalkEntitiesInto( WalkEntitiesInto(
ToEntries(landblockEntries), ToEntries(landblockEntries),
@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
visibleCellIds, visibleCellIds,
animatedEntityIds, animatedEntityIds,
_walkScratch, _walkScratch,
ref walkResult); ref walkResult,
probeState);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of // per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
@ -582,6 +685,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ulong gfxObjId = meshRef.GfxObjId; ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId); var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
// [indoor-lookup] probe — emit once per cell entity per sec.
// Fires BEFORE the null-renderData early-continue so a miss still
// emits hit=false, distinguishing H2 (empty batches) from H6
// (dispatcher fails to traverse Setup).
ulong lookupCellId = (ulong)gfxObjId;
if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
// Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
// (which key on the same gfxObjId). Without this, IndoorAll=1 would
// silence the lookup probe whenever the walk probe fired first.
&& ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
{
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}");
}
if (renderData is null) if (renderData is null)
{ {
// Tier 1 cache (#53): mesh data is still async-decoding via // Tier 1 cache (#53): mesh data is still async-decoding via
@ -614,6 +753,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var model = ComposePartWorldMatrix( var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform); entityWorld, meshRef.PartTransform, partTransform);
// [indoor-xform] probe — only for the cell's synthetic
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
// cellGeomId convention). One line per part per sec.
// Disambiguates hypothesis H5 (transform double-apply —
// composedT lands at 2 × cellOrigin).
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})");
}
var restPose = partTransform * meshRef.PartTransform; var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector); ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
drewAny = true; drewAny = true;
@ -1289,6 +1445,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
/// <summary>
/// Thin wrapper around an instance's rate-limit dictionary + frame
/// counter, passed into the static <see cref="WalkEntitiesInto"/>
/// overload so it can emit rate-limited probe lines without access
/// to instance fields. Null = probes disabled (test-friendly overload).
/// </summary>
internal sealed class IndoorProbeState
{
private readonly Dictionary<ulong, int> _lastFrame;
private readonly int _currentFrame;
private const int RateLimit = IndoorProbeRateLimitFrames;
internal IndoorProbeState(Dictionary<ulong, int> lastFrame, int currentFrame)
{
_lastFrame = lastFrame;
_currentFrame = currentFrame;
}
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per <paramref name="cellId"/>. Side-effect: stamps the frame
/// number into the dictionary on success.
/// </summary>
internal bool ShouldEmit(ulong cellId)
{
if (!_lastFrame.TryGetValue(cellId, out int last)
|| _currentFrame - last >= RateLimit)
{
_lastFrame[cellId] = _currentFrame;
return true;
}
return false;
}
}
private sealed class InstanceGroup private sealed class InstanceGroup
{ {
public uint Ibo; public uint Ibo;

View file

@ -1,6 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AcDream.Core.Meshing; using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using Chorizite.OpenGLSDLBackend; using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib; using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter; using DatReaderWriter;
@ -34,6 +37,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new(); private readonly HashSet<ulong> _metadataPopulated = new();
/// <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();
/// <summary> /// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>; /// True when this instance was created via <see cref="CreateUninitialized"/>;
/// all public methods no-op when uninitialized. /// all public methods no-op when uninitialized.
@ -65,10 +77,52 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_dats = dats; _dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir); _wbDats = new DefaultDatReaderWriter(datDir);
// Phase 2 diagnostic — replace NullLogger with a Console-backed
// logger so WB's internal catch block at ObjectMeshManager.cs:589
// (and similar) surfaces its swallowed exceptions instead of
// dropping them. ConsoleErrorLogger filters to LogLevel.Error+
// so successful operations stay quiet.
_meshManager = new ObjectMeshManager( _meshManager = new ObjectMeshManager(
_graphicsDevice, _graphicsDevice,
_wbDats, _wbDats,
NullLogger<ObjectMeshManager>.Instance); new ConsoleErrorLogger<ObjectMeshManager>());
}
/// <summary>
/// Minimal Console-backed logger that fires only on
/// <see cref="LogLevel.Error"/> and above. Format:
/// <code>[wb-error] &lt;message&gt;
/// [wb-error] &lt;ExceptionType&gt;: &lt;ExceptionMessage&gt;
/// [wb-error] at &lt;frame&gt; (up to 5 frames)</code>
/// Used to surface WB's silently-caught exceptions in
/// <c>ObjectMeshManager.PrepareMeshData</c>.
/// </summary>
private sealed class ConsoleErrorLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
Console.WriteLine($"[wb-error] {message}");
if (exception is not null)
{
Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
var stack = (exception.StackTrace ?? "")
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Take(5);
foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
}
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
} }
private WbMeshAdapter() private WbMeshAdapter()
@ -133,7 +187,80 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded // isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused. // unused.
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false); var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
// [indoor-upload] requested probe — only for EnvCell ids.
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
{
bool hadRenderDataAtRequest = _meshManager.HasRenderData(id);
_pendingEnvCellRequests.Add(id);
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}");
// Phase 2 — surface what WB's catch block silently swallows.
// ObjectMeshManager.PrepareMeshData has a try/catch at line 589
// that calls _logger.LogError(ex, ...) — but we construct
// ObjectMeshManager with NullLogger.Instance so the log is
// dropped. This continuation captures the same data scoped to
// EnvCell ids only. Runs on ThreadPool; non-blocking. Zero cost
// when the probe is off.
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)
{
// Phase 2 cause-narrowing: WB's PrepareMeshData can return
// null for several reasons (ResolveId empty / TryGet<EnvCell>
// failed / type Unknown). Cross-check against acdream's own
// DatCollection — if WE find the cell but WB doesn't, the
// divergence is between dat readers, not a missing record.
bool ourCellFound = false;
try
{
ourCellFound = _dats?.Cell.TryGet<DatReaderWriter.DBObjs.EnvCell>(
(uint)cellId, out _) ?? false;
}
catch { /* swallow — this is best-effort diagnostic */ }
int wbResolveCount = -1;
string wbSelectedType = "none";
bool wbDbTryGetEnvCell = false;
bool wbDbIsPortal = false;
try
{
var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList();
wbResolveCount = wbResolutions?.Count ?? -1;
if (wbResolutions is not null && wbResolutions.Count > 0)
{
var selected = wbResolutions
.OrderByDescending(r => r.Database == _wbDats!.Portal)
.First();
wbSelectedType = selected.Type.ToString();
wbDbIsPortal = selected.Database == _wbDats!.Portal;
try { wbDbTryGetEnvCell = selected.Database.TryGet<DatReaderWriter.DBObjs.EnvCell>((uint)cellId, out _); } catch {}
}
}
catch { /* swallow — best-effort */ }
Console.WriteLine(
$"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " +
$"ourCellDb.TryGet={ourCellFound} " +
$"wbResolveId.Count={wbResolveCount} " +
$"wbSelectedType={wbSelectedType} " +
$"wbDbIsPortal={wbDbIsPortal} " +
$"wbDbTryGet<EnvCell>={wbDbTryGetEnvCell}");
}
}, TaskScheduler.Default);
}
} }
} }
@ -172,7 +299,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_graphicsDevice!.ProcessGLQueue(); _graphicsDevice!.ProcessGLQueue();
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData)) while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{ {
_meshManager.UploadMeshData(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}");
}
} }
} }

View file

@ -0,0 +1,109 @@
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;
}

View file

@ -254,6 +254,27 @@ public sealed class DebugPanel : IPanel
if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)",
ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk; ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;
// ── 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;
r.Spacing(); r.Spacing();
// Cycle / toggle actions live on the VM as Action handles; the // Cycle / toggle actions live on the VM as Action handles; the

View file

@ -291,6 +291,72 @@ public sealed class DebugVM
set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value; set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value;
} }
// ── 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. No dedicated env var; set any individual
/// probe env var or use <c>ACDREAM_PROBE_INDOOR_ALL</c> to initialize
/// all five flags on at startup.
/// </summary>
public bool ProbeIndoorAll
{
get => RenderingDiagnostics.IndoorAll;
set => RenderingDiagnostics.IndoorAll = value;
}
// ── Chase camera tunables (forward to CameraDiagnostics) ────────── // ── Chase camera tunables (forward to CameraDiagnostics) ──────────
/// <summary>Runtime mirror of <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.</summary> /// <summary>Runtime mirror of <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.</summary>

View file

@ -0,0 +1,92 @@
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
public sealed class RenderingDiagnosticsTests
{
// Each flag-mutating test snapshots the IndoorAll state on entry and
// restores it via try/finally. RenderingDiagnostics is a process-wide
// static (env-var-initialized); without restoration a mutated state
// leaks into other tests + into parallel test runs. Mirrors the
// PhysicsDiagnosticsTests pattern at line 30-49.
[Fact]
public void IndoorAll_True_TurnsAllFlagsOn()
{
bool initial = RenderingDiagnostics.IndoorAll;
try
{
// 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);
}
finally
{
RenderingDiagnostics.IndoorAll = initial;
}
}
[Fact]
public void IndoorAll_False_TurnsAllFlagsOff()
{
bool initial = RenderingDiagnostics.IndoorAll;
try
{
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);
}
finally
{
RenderingDiagnostics.IndoorAll = initial;
}
}
[Fact]
public void IndoorAll_OneOff_ReadsAsFalse()
{
bool initial = RenderingDiagnostics.IndoorAll;
try
{
RenderingDiagnostics.IndoorAll = true;
RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
Assert.False(RenderingDiagnostics.IndoorAll);
}
finally
{
RenderingDiagnostics.IndoorAll = initial;
}
}
[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));
}
}