diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bce9fec..91fe17b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,7 +46,281 @@ Copy this block when adding a new issue: # 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 **Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c2e403c..ba54f0c 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # 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 30–50×); 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` 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 30–50×); 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. --- @@ -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.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 ✓ | +| 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` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` 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(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(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 ✓ | Plus polish that doesn't get its own phase number: diff --git a/docs/research/2026-05-19-indoor-cell-rendering-cause.md b/docs/research/2026-05-19-indoor-cell-rendering-cause.md new file mode 100644 index 0000000..682a6a4 --- /dev/null +++ b/docs/research/2026-05-19-indoor-cell-rendering-cause.md @@ -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(stab.Id, out var stabSetup)) { // ← throws +``` + +WB iterates `envCell.StaticObjects` and **blindly calls `TryGet` 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=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`: + +```csharp +// Before: +if (_dats.Portal.TryGet(stab.Id, out var stabSetup)) { + +// After: +if ((stab.Id & 0xFF000000u) == 0x02000000u + && _dats.Portal.TryGet(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` + `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. diff --git a/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md new file mode 100644 index 0000000..a41577b --- /dev/null +++ b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md @@ -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 14–86 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 (1–46) 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` 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` 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. diff --git a/docs/research/2026-05-19-indoor-cell-rendering-verification.md b/docs/research/2026-05-19-indoor-cell-rendering-verification.md new file mode 100644 index 0000000..0e89080 --- /dev/null +++ b/docs/research/2026-05-19-indoor-cell-rendering-verification.md @@ -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` deserialization of GfxObj-typed stab ids. + +Total runtime for Phase 2: ~4 client launches. diff --git a/docs/research/deepdives/r13-dynamic-lighting.md b/docs/research/deepdives/r13-dynamic-lighting.md index 91067d1..b1321a5 100644 --- a/docs/research/deepdives/r13-dynamic-lighting.md +++ b/docs/research/deepdives/r13-dynamic-lighting.md @@ -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. - **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` diff --git a/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md new file mode 100644 index 0000000..53c35ef --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md @@ -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 (H1–H6 in the spec) the failure matches. + +**Tech Stack:** C# .NET 10, xUnit (test framework), Silk.NET OpenGL (rendering), Chorizite.OpenGLSDLBackend (WB ObjectMeshManager). + +**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../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; + +/// +/// 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. +/// +/// +/// Mirrors the L.2a +/// pattern. The master toggle is the user's +/// common case — flipping it cascades to all five probe flags. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md. +/// +/// +public static class RenderingDiagnostics +{ + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-walk] 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 ACDREAM_PROBE_INDOOR_WALK=1. + /// + public static bool ProbeIndoorWalkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-lookup] + /// line per visible cell entity per second: render-data hit/miss, + /// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies. + /// Initial state from ACDREAM_PROBE_INDOOR_LOOKUP=1. + /// + public static bool ProbeIndoorLookupEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbMeshAdapter emits two lines per EnvCell id: + /// [indoor-upload] requested on first IncrementRefCount and + /// [indoor-upload] completed when WB's staged drain produces + /// its ObjectMeshData. Missing "completed" lines indicate WB + /// silently returned null (hypothesis H1). + /// Initial state from ACDREAM_PROBE_INDOOR_UPLOAD=1. + /// + public static bool ProbeIndoorUploadEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-xform] + /// line per visible cell entity per second: cell-geometry SetupPart's + /// composed world matrix translation. Disambiguates transform + /// double-apply (hypothesis H5). + /// Initial state from ACDREAM_PROBE_INDOOR_XFORM=1. + /// + public static bool ProbeIndoorXformEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-cull] line per cell entity that gets culled, with + /// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates + /// cull bugs (hypothesis H3). + /// Initial state from ACDREAM_PROBE_INDOOR_CULL=1. + /// + public static bool ProbeIndoorCullEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// Master toggle. Reading reflects the AND of all five flags + /// (true only when every probe is on). Writing cascades — setting + /// to turns ALL five flags on; setting to + /// turns ALL five off. + /// + public static bool IndoorAll + { + get => ProbeIndoorWalkEnabled + && ProbeIndoorLookupEnabled + && ProbeIndoorUploadEnabled + && ProbeIndoorXformEnabled + && ProbeIndoorCullEnabled; + set + { + ProbeIndoorWalkEnabled = value; + ProbeIndoorLookupEnabled = value; + ProbeIndoorUploadEnabled = value; + ProbeIndoorXformEnabled = value; + ProbeIndoorCullEnabled = value; + } + } + + /// + /// Helper for probe call sites. Returns when + /// the low 16 bits of are ≥ 0x0100 — the AC + /// convention for EnvCell (indoor) cells, as opposed to outdoor cells + /// in the 8×8 landblock grid (0x0001–0x0040). + /// + 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) +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) +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. + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorWalkEnabled + /// (env var ACDREAM_PROBE_INDOOR_WALK). + /// + public bool ProbeIndoorWalk + { + get => RenderingDiagnostics.ProbeIndoorWalkEnabled; + set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorLookupEnabled + /// (env var ACDREAM_PROBE_INDOOR_LOOKUP). + /// + public bool ProbeIndoorLookup + { + get => RenderingDiagnostics.ProbeIndoorLookupEnabled; + set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorUploadEnabled + /// (env var ACDREAM_PROBE_INDOOR_UPLOAD). + /// + public bool ProbeIndoorUpload + { + get => RenderingDiagnostics.ProbeIndoorUploadEnabled; + set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorXformEnabled + /// (env var ACDREAM_PROBE_INDOOR_XFORM). + /// + public bool ProbeIndoorXform + { + get => RenderingDiagnostics.ProbeIndoorXformEnabled; + set => RenderingDiagnostics.ProbeIndoorXformEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorCullEnabled + /// (env var ACDREAM_PROBE_INDOOR_CULL). + /// + public bool ProbeIndoorCull + { + get => RenderingDiagnostics.ProbeIndoorCullEnabled; + set => RenderingDiagnostics.ProbeIndoorCullEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all + /// five indoor probes together. + /// + 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) +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) +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 + /// + /// 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). + /// + private readonly HashSet _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) +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 + /// + /// 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. + /// + private readonly Dictionary _lastIndoorProbeFrame = new(); + private int _indoorProbeFrameCounter; + private const int IndoorProbeRateLimitFrames = 30; + + /// + /// Returns true at most once per + /// frames per cellId. Caller must already have checked that an indoor + /// probe flag is enabled. + /// + 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) +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) +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) +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. diff --git a/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md b/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md new file mode 100644 index 0000000..c1abc26 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md @@ -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` 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=: + stack=[] + [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) +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 — + +Captured at Holtburg with the ContinueWith-based exception surfacer +from Task 1. + +Fix shape decided: . Implemented in next commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +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(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(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 Holtburg cells silently failing with + thrown at : when WB tried to look up +surface 0x... that isn't resolvable in the loaded portal dat. + +Patch: . + +Visual-verified: floor now renders in Holtburg Inn. + +Co-Authored-By: Claude Opus 4.7 (1M context) +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(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(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: +- Now emit `[indoor-upload] completed cellId=0x... isSetup=True hasEnvCellGeom=True cellGeomVerts= uploadOk=True` +- No new `[indoor-upload] FAILED` or `NULL_RESULT` lines for these cells. + +## Visual confirmation + +User walked into: +- Holtburg Inn — floor visible. ✓ +- — 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 . + +Phase 2 complete. + +Co-Authored-By: Claude Opus 4.7 (1M context) +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 +| | 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: . Fix: . 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) +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. diff --git a/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md b/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md new file mode 100644 index 0000000..f433375 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md b/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md new file mode 100644 index 0000000..bbc7c57 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md @@ -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.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` 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=: stack=[]`. Unwrap `AggregateException.InnerException` for cleaner output. +- **Task succeeded with null result** → `[indoor-upload] NULL_RESULT cellId=0x...`. WB's deliberate null-return path (e.g., `ResolveId` returned empty, type was `Unknown`). +- **Task succeeded with non-null result** → no extra log. The existing `Tick()` drain already emits `[indoor-upload] completed`. + +The continuation: +- Runs on `TaskScheduler.Default` (`ThreadPool`) so it doesn't block the render thread. +- Only attached for EnvCell IDs (gated by `RenderingDiagnostics.IsEnvCellId(id)`) when `ProbeIndoorUploadEnabled` is true — zero cost when off. +- Captures `cellId` (a `ulong` value) only; no instance closure leakage. +- Truncates stack trace to top 3 frames. + +Concrete code shape: + +```csharp +if (_metadataPopulated.Add(id)) +{ + PopulateMetadata(id); + var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false); + + if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled) + { + _pendingEnvCellRequests.Add(id); + Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}"); + + ulong cellId = id; + _ = prepTask.ContinueWith(t => + { + if (t.IsFaulted && t.Exception is not null) + { + var ex = t.Exception.InnerException ?? t.Exception; + var stack = (ex.StackTrace ?? "").Split('\n') + .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0); + Console.WriteLine( + $"[indoor-upload] FAILED cellId=0x{cellId:X8} " + + $"exception={ex.GetType().Name}: {ex.Message} " + + $"stack=[{string.Join(" | ", stack)}]"); + } + else if (t.IsCompletedSuccessfully && t.Result is null) + { + Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}"); + } + }, TaskScheduler.Default); + } +} +``` + +`using System.Linq;` and `using System.Threading.Tasks;` may need adding (likely already present). + +### Component 2 — Capture procedure + +Standard launch: + +```powershell +$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log +``` + +Walk into Holtburg Inn, walk into nearby buildings whose cells were on the missing-26 list (`0xA9B40100`, `0xA9B40111`, etc.). Close gracefully. + +Analyze: + +```powershell +Get-Content launch.log | + Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' } | + Select-Object -Unique +``` + +Expected output: a per-cell list of distinct exception types or null-return signals. Most cells likely share 1–3 root causes. + +### Component 3 — Targeted fix (shape unknown until Component 2 captures) + +Once Component 2 reveals the exception type + message, the fix is one localized code change. Likely shapes: + +| Captured cause | Fix shape | +|---|---| +| Texture decode `Exception` (e.g. `KeyNotFoundException` on surface ID) | Guard at `WbMeshAdapter.PopulateMetadata` or pre-validate surfaces; possibly patch WB fork. | +| `KeyNotFoundException` for missing `Environment` / `CellStruct` | Log + skip cell with a sentinel render-data; report which dat is stale. | +| `NullReferenceException` in `PrepareCellStructMeshData` | Add null guard at the specific call site. | +| WB internal logic bug | Fork patch to WB. | +| `NULL_RESULT` (ResolveId returned empty / type was Unknown) | Investigate dat file integrity; possibly user needs a dat update. | + +The fix is one or two code edits, lands as a single commit, and is followed by a re-launch verifying: +- `[indoor-upload] FAILED` / `NULL_RESULT` lines disappear for the previously-failing cells. +- `[indoor-upload] completed` appears for those cells. +- Visual verification: floor renders in Holtburg Inn. + +--- + +## 3. Edge cases + +| Scenario | Behavior | +|---|---| +| Probe toggled off mid-session | Continuation still emits if attached at request time. Acceptable — capturing the cause once matters more than honoring runtime toggle. | +| Continuation fires after adapter disposed | Harmless console write on dying process. No memory leak; closure captures only the `ulong` cellId. | +| Same cell requested twice | `_metadataPopulated.Add(id)` guards; continuation attaches exactly once. Re-streaming after Remove+Add keeps the sticky set. First failure is what we want. | +| Cancellation | `t.IsCanceled` is neither `IsFaulted` nor `IsCompletedSuccessfully`. Continuation silently skips. Acceptable — cancellation isn't a failure cause. | +| `Task.Result` on faulted task | Re-throws AggregateException. Our gate `else if (t.IsCompletedSuccessfully && t.Result is null)` ensures we never read Result without a clean success state. | +| WB's `_logger.LogError` for the same exception | WbMeshAdapter passes `NullLogger` — WB's log goes nowhere. Our continuation is what surfaces it. Discussed below. | + +**Why not just inject a real logger into `ObjectMeshManager`?** Could replace `NullLogger.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` (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=: 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. diff --git a/references/WorldBuilder b/references/WorldBuilder index 167788b..34460c4 160000 --- a/references/WorldBuilder +++ b/references/WorldBuilder @@ -1 +1 @@ -Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81 +Subproject commit 34460c44d7fb921afa50ee30288a53236f50f451 diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index f3e0c55..bcca4ec 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -330,6 +330,21 @@ public sealed class CellVisibility local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon; } + /// + /// Brute-force scan of every loaded cell to test whether + /// is inside any of them. Does not touch + /// the camera cache (), so this is safe + /// to call alongside in the same frame + /// for a different position (e.g. player position when the camera is + /// in third-person chase mode). + /// + public bool IsInsideAnyCell(Vector3 worldPoint) + { + foreach (var cell in _cellLookup.Values) + if (PointInCell(worldPoint, cell)) return true; + return false; + } + // ------------------------------------------------------------------ // GetVisibleCells (BFS) // ------------------------------------------------------------------ diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ff777f8..13a660c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -6848,6 +6848,19 @@ public sealed class GameWindow : IDisposable var visibility = _cellVisibility.ComputeVisibility(camPos); 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 // retail decomp confirms SkyObject.PesObjectId is copied by // 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 // consumes binding=1 reads the same data for the rest of the // frame — terrain, static mesh, instanced mesh, sky. - UpdateSunFromSky(kf, cameraInsideCell); + UpdateSunFromSky(kf, playerInsideCell); Lighting.Tick(camPos); var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( 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) /// from the interpolated , /// 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 CellManager::ChangePosition @ 0x004559B0, + /// which calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) + /// when the player's CObjCell::seen_outside flag is 0. + /// Indoor brightness then comes from per-cell point lights + /// (Setup.Lights on the cell's static objects, registered through + /// ). + /// 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. /// - 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 // shader does dot(N, -forward) so a positive N·L means the // surface faces the sun. 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 { Kind = AcDream.Core.Lighting.LightKind.Directional, @@ -8340,7 +8363,7 @@ public sealed class GameWindow : IDisposable Range = 1f, }; 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, SunDirection: sunToWorld); } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 36ebdc9..6dbf0c8 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; using AcDream.Core.Meshing; +using AcDream.Core.Rendering; using AcDream.Core.Terrain; using AcDream.Core.World; using Chorizite.OpenGLSDLBackend.Lib; @@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private bool _disposed; + /// + /// 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. + /// + private readonly Dictionary _lastIndoorProbeFrame = new(); + private int _indoorProbeFrameCounter; + private const int IndoorProbeRateLimitFrames = 30; + + /// + /// Returns true at most once per + /// frames per cellId. Caller must already have checked that an indoor + /// probe flag is enabled. + /// + 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. private int _entitiesSeen; private int _entitiesDrawn; @@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// list. reuses a per-dispatcher scratch field across frames to /// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs. /// Returns walk count via 's EntitiesWalked field. + /// + /// + /// When is non-null the method emits + /// [indoor-cull] lines for cell entities rejected by the + /// visibleCellIds or frustum filters, and [indoor-walk] lines for + /// cell entities that pass all filters. Rate-limited by + /// . Pass (the default) + /// to disable all probe emission — used by the test-friendly + /// overload. + /// /// internal static void WalkEntitiesInto( IEnumerable landblockEntries, @@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable HashSet? visibleCellIds, HashSet? animatedEntityIds, List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch, - ref WalkResult result) + ref WalkResult result, + IndoorProbeState? indoorProbeState = null) { scratch.Clear(); result.EntitiesWalked = 0; @@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { if (entity.MeshRefs.Count == 0) continue; - if (entity.ParentCellId.HasValue && visibleCellIds is not null - && !visibleCellIds.Contains(entity.ParentCellId.Value)) + // Detect cell entity for indoor probes — first MeshRef.GfxObjId + // 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; + } // Per-entity AABB frustum cull (perf #3). Animated entities bypass — // they're tracked at landblock level + need per-frame work regardless. // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + bool aabbVisible = 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; + aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax); + } + + 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++; @@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable HashSet? animatedEntityIds = null) { _shader.Use(); + _indoorProbeFrameCounter++; var vp = camera.View * camera.Projection; _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 // that populates _walkScratch (a per-dispatcher field reused across frames) // 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); WalkEntitiesInto( ToEntries(landblockEntries), @@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable visibleCellIds, animatedEntityIds, _walkScratch, - ref walkResult); + ref walkResult, + probeState); // Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple // 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; 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) { // Tier 1 cache (#53): mesh data is still async-decoding via @@ -614,6 +753,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable var model = ComposePartWorldMatrix( 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; ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector); drewAny = true; @@ -1289,6 +1445,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // ──────────────────────────────────────────────────────────────────────── + /// + /// Thin wrapper around an instance's rate-limit dictionary + frame + /// counter, passed into the static + /// overload so it can emit rate-limited probe lines without access + /// to instance fields. Null = probes disabled (test-friendly overload). + /// + internal sealed class IndoorProbeState + { + private readonly Dictionary _lastFrame; + private readonly int _currentFrame; + private const int RateLimit = IndoorProbeRateLimitFrames; + + internal IndoorProbeState(Dictionary lastFrame, int currentFrame) + { + _lastFrame = lastFrame; + _currentFrame = currentFrame; + } + + /// + /// Returns true at most once per + /// frames per . Side-effect: stamps the frame + /// number into the dictionary on success. + /// + 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 { public uint Ibo; diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index b57e043..2729747 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using AcDream.Core.Meshing; +using AcDream.Core.Rendering; using Chorizite.OpenGLSDLBackend; using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; @@ -34,6 +37,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter private readonly AcSurfaceMetadataTable _metadataTable = new(); private readonly HashSet _metadataPopulated = new(); + /// + /// 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). + /// + private readonly HashSet _pendingEnvCellRequests = new(); + /// /// True when this instance was created via ; /// all public methods no-op when uninitialized. @@ -65,10 +77,52 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _dats = dats; _graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings()); _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( _graphicsDevice, _wbDats, - NullLogger.Instance); + new ConsoleErrorLogger()); + } + + /// + /// Minimal Console-backed logger that fires only on + /// and above. Format: + /// [wb-error] <message> + /// [wb-error] <ExceptionType>: <ExceptionMessage> + /// [wb-error] at <frame> (up to 5 frames) + /// Used to surface WB's silently-caught exceptions in + /// ObjectMeshManager.PrepareMeshData. + /// + private sealed class ConsoleErrorLogger : ILogger + { + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func 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() @@ -133,7 +187,80 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // 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); + 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 + // 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( + (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((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={wbDbTryGetEnvCell}"); + } + }, TaskScheduler.Default); + } } } @@ -172,7 +299,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter _graphicsDevice!.ProcessGLQueue(); 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}"); + } } } diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs new file mode 100644 index 0000000..ff0a32f --- /dev/null +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -0,0 +1,109 @@ +using System; + +namespace AcDream.Core.Rendering; + +/// +/// 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. +/// +/// +/// Mirrors the L.2a +/// pattern. The master toggle is the user's +/// common case — flipping it cascades to all five probe flags. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md. +/// +/// +public static class RenderingDiagnostics +{ + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-walk] 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 ACDREAM_PROBE_INDOOR_WALK=1. + /// + public static bool ProbeIndoorWalkEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-lookup] + /// line per visible cell entity per second: render-data hit/miss, + /// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies. + /// Initial state from ACDREAM_PROBE_INDOOR_LOOKUP=1. + /// + public static bool ProbeIndoorLookupEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbMeshAdapter emits two lines per EnvCell id: + /// [indoor-upload] requested on first IncrementRefCount and + /// [indoor-upload] completed when WB's staged drain produces + /// its ObjectMeshData. Missing "completed" lines indicate WB + /// silently returned null (hypothesis H1). + /// Initial state from ACDREAM_PROBE_INDOOR_UPLOAD=1. + /// + public static bool ProbeIndoorUploadEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher emits one [indoor-xform] + /// line per visible cell entity per second: cell-geometry SetupPart's + /// composed world matrix translation. Disambiguates transform + /// double-apply (hypothesis H5). + /// Initial state from ACDREAM_PROBE_INDOOR_XFORM=1. + /// + public static bool ProbeIndoorXformEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// When true, WbDrawDispatcher.WalkVisibleEntities emits one + /// [indoor-cull] line per cell entity that gets culled, with + /// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates + /// cull bugs (hypothesis H3). + /// Initial state from ACDREAM_PROBE_INDOOR_CULL=1. + /// + public static bool ProbeIndoorCullEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1" + || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1"; + + /// + /// Master toggle. Reading reflects the AND of all five flags + /// (true only when every probe is on). Writing cascades — setting + /// to turns ALL five flags on; setting to + /// turns ALL five off. + /// + public static bool IndoorAll + { + get => ProbeIndoorWalkEnabled + && ProbeIndoorLookupEnabled + && ProbeIndoorUploadEnabled + && ProbeIndoorXformEnabled + && ProbeIndoorCullEnabled; + set + { + ProbeIndoorWalkEnabled = value; + ProbeIndoorLookupEnabled = value; + ProbeIndoorUploadEnabled = value; + ProbeIndoorXformEnabled = value; + ProbeIndoorCullEnabled = value; + } + } + + /// + /// Helper for probe call sites. Returns when + /// the low 16 bits of are ≥ 0x0100 — the AC + /// convention for EnvCell (indoor) cells, as opposed to outdoor cells + /// in the 8×8 landblock grid (0x0001–0x0040). + /// + public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs index 2ee203d..bcf58be 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -254,6 +254,27 @@ public sealed class DebugPanel : IPanel if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", 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(); // Cycle / toggle actions live on the VM as Action handles; the diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index baf1055..b051dc0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -291,6 +291,72 @@ public sealed class DebugVM 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. + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorWalkEnabled + /// (env var ACDREAM_PROBE_INDOOR_WALK). + /// + public bool ProbeIndoorWalk + { + get => RenderingDiagnostics.ProbeIndoorWalkEnabled; + set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorLookupEnabled + /// (env var ACDREAM_PROBE_INDOOR_LOOKUP). + /// + public bool ProbeIndoorLookup + { + get => RenderingDiagnostics.ProbeIndoorLookupEnabled; + set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorUploadEnabled + /// (env var ACDREAM_PROBE_INDOOR_UPLOAD). + /// + public bool ProbeIndoorUpload + { + get => RenderingDiagnostics.ProbeIndoorUploadEnabled; + set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorXformEnabled + /// (env var ACDREAM_PROBE_INDOOR_XFORM). + /// + public bool ProbeIndoorXform + { + get => RenderingDiagnostics.ProbeIndoorXformEnabled; + set => RenderingDiagnostics.ProbeIndoorXformEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.ProbeIndoorCullEnabled + /// (env var ACDREAM_PROBE_INDOOR_CULL). + /// + public bool ProbeIndoorCull + { + get => RenderingDiagnostics.ProbeIndoorCullEnabled; + set => RenderingDiagnostics.ProbeIndoorCullEnabled = value; + } + + /// + /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all + /// five indoor probes together. No dedicated env var; set any individual + /// probe env var or use ACDREAM_PROBE_INDOOR_ALL to initialize + /// all five flags on at startup. + /// + public bool ProbeIndoorAll + { + get => RenderingDiagnostics.IndoorAll; + set => RenderingDiagnostics.IndoorAll = value; + } + // ── Chase camera tunables (forward to CameraDiagnostics) ────────── /// Runtime mirror of . diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs new file mode 100644 index 0000000..f490b36 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -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)); + } +}